feat: unify all data in ~/.atuin (customizable with ATUIN_HOME) (atuinsh/atuin#4380)

This PR unifies the location of all Atuin data into the `~/.atuin` directory that we already create. New installations will store data in `~/.atuin/data` rather than `~/.local/share/atuin`, and config items like `config.toml` and `themes` directly in `~/.atuin` rather than in `~/.config/atuin/`.

Atuin will continue to use the old locations if the new ones don't exist. This is done on a granular[^1] basis; if `~/.atuin/data` doesn't exist, Atuin will load data from `~/.local/share/atuin`, even if `~/.atuin` contains other items like `config.toml` or `themes`. Existing installations will continue to work as-is and will not be automatically migrated. If you wish to migrate your installation, [follow these steps](#migrating-existing-installations).

The location of `~/.atuin` can be customized with the `ATUIN_HOME` environment variable. It must be an absolute path; otherwise it will be ignored. If you use `ATUIN_HOME` to switch between multiple different profiles, you should set [`daemon.socket_path`] explicitly in each, to avoid connecting to the wrong daemon.

[^1]: Generally, each *top-level* item is looked up separately. For example, you can have `~/.atuin/themes` and `~/.config/atuin/config.toml` simultaneously, but if you have `~/.atuin/themes/a.toml` and `~/.config/atuin/themes/b.toml`, Atuin will only load `~/.atuin/themes` and won't find `b.toml`. There is an exception for AI-related items (`permissions.ai.toml`, `skills`, and `TERMINAL.md`): they're looked up in `~/.atuin` only if `~/.atuin/config.toml` exists, because older installations may have created those same files but with a different meaning (scoped to `$HOME` rather than global). For more information, see the [migration instructions](#migrating-existing-installations).

[`daemon.socket_path`]: https://docs.atuin.sh/latest/configuration/config/#socket_path

### Why make this change?

As Atuin continues to gain new features that require managing ever-growing types and amounts of data, we feel that storing everything in a single directory is the best way to ensure users get a predictable, reliable experience across all system types. Using a single directory makes it easier for users to locate all Atuin-related data on their system, back up or migrate that data to a new system, and ensure that every running instance of Atuin is accessing the same configuration and databases (a concern when the daemon is started in a different environment from the client). It also makes it easier to completely uninstall Atuin, which, although we hope our users never feel the need to do, is something we believe should be easy and straightforward.

The introduction of `ATUIN_HOME` in this PR also makes it easier to use multiple independent Atuin “profiles”, each with their own data and config.[^2] This is very difficult to accomplish currently: setting `XDG_CONFIG_HOME` and `XDG_DATA_HOME` mostly works, but those environment variables get propagated to child processes and will be inherited by, for example, a shell spawned by the pty-proxy. There are some Atuin-specific variables like `ATUIN_CONFIG_DIR`, but they only affect the location of some items.

For some background, Atuin's current file layout is inconsistent and nonstandard. Although Atuin is sometimes described as following the [XDG Base Directory Specification][xdg], it is only partially compliant. Although Atuin uses `$XDG_CONFIG_HOME` and `$XDG_DATA_HOME`, it:

* Stores logs in `~/.atuin/logs`, rather than `$XDG_STATE_HOME`.
* Stores the `atuin` binary in `~/.atuin/bin`, rather than `~/.local/bin`, when installed from the install script.
* Stores sockets in `/tmp/atuin-$UID`, rather than `$XDG_RUNTIME_DIR`.
* Stored the dotfiles cache in `$XDG_DATA_HOME` rather than `$XDG_CACHE_HOME`.
* Stores pidfiles and file locks in `$XDG_DATA_HOME` rather than `$XDG_RUNTIME_DIR`.
* Uses XDG Base Directories on Windows and macOS, where doing so is not standard.

[xdg]: https://specifications.freedesktop.org/basedir/latest/

[^2]: If you do this, you should set [`daemon.socket_path`] in each profile's `config.toml` to avoid connecting to the wrong daemon.

### Why not use the “correct” paths?

There are several reasons why “just using the correct paths” isn't so straightforward:

* The XDG Base Directory Specification is primarily a Linux specification. On Windows, it is atypical and nonstandard to follow the XDG Base Directory Specification; rather, directories like `%USERPROFILE%\AppData\Local` should be used. Aside from being potentially quite unintuitive to our users, Windows does not seem to have a convention for distinguishing between cache, config, and data directories; in [`dirs`], for example, [`cache_dir`], [`config_local_dir`], and [`data_local_dir`] all return the exact same path on Windows. This leaves issues waiting to happen if we store a file with the same name in the config and data directories, which would be different on Linux but the same on Windows.

  The situation on macOS isn't much better. In practice, some command-line tools follow the XDG Base Directory Specification on macOS, but there is no consensus on whether this is correct, as macOS has its own official directories for application data, such as `~/Library/Application Support` and `~/Library/Caches`. As with Windows, macOS's set of user directories does not map cleanly onto the XDG Base Directory Specification; [`config_local_dir`] and [`data_local_dir`] return the same path on macOS, thus risking the same issue with files having the same name.

  It is worth noting that even if these issues were to be resolved, and Atuin used the “correct” directories for each operating system type without conflicts, this would require providing three sets of instructions for any operation that touches Atuin's files: rather than “edit `~/.atuin/config.toml`”, we would have to say “edit `~/.config/atuin/config.toml` on Linux, `~/Library/Application Support/atuin/config.toml` on macOS, or `~\AppData\Local\atuin\config.toml` on Windows”. Providing a consistent experience across systems is one of the main reasons we've decided to move to `~/.atuin`.

* Environment variables can differ between the daemon and client. Atuin used to store the daemon socket in `$XDG_RUNTIME_DIR`; however, this caused issues for users whose daemons were launched in environments that did not have `XDG_RUNTIME_DIR` set, but whose shells did, as the client would fail to find the daemon socket. This would be less of an issue today, now that the socket path is stored in the daemon pidfile, however it is arguably incorrect that the pidfile is stored in `$XDG_DATA_HOME` since it is relevant only when a running instance of Atuin exists. But storing the pidfile in `$XDG_RUNTIME_DIR` would simply reintroduce the same issue.

  Additionally, when the socket was stored in `$XDG_RUNTIME_DIR`, the behavior of the daemon when `XDG_RUNTIME_DIR` was unset was not spec-compliant, as the daemon would fall back to storing it in `$XDG_DATA_HOME`. Being spec-compliant is not trivial, however: the XDG Base Directory Specification says that applications “should fall back to a replacement directory with similar capabilities”, but among the capabilities mandated of `$XDG_RUNTIME_DIR` are that “if the user fully logs out the directory MUST be removed”. It is not straightforward for Atuin to produce a fallback directory with this capability when `XDG_RUNTIME_DIR` is unset, and even if it could, it would not solve the daemon–client mismatch issue.

* In our opinion, not all of the XDG Base Directories have the same level of discoverability as `~/.atuin`. This is particularly the case for logs, and it is why even today Atuin stores logs in `~/.atuin/logs`. The correct place for logs in the XDG Base Directory Specification is `$XDG_STATE_HOME`, falling back to `~/.local/state`, though note that `XDG_STATE_HOME` was added to the spec only in 2021. We do not think that our users would be as likely to check `~/.local/state/atuin` for logs as `~/.atuin`; many likely have not heard of `~/.local/state` at all.

* Atuin's installer, [cargo-dist], is not set up to install to the XDG Base Directories; it is tailored towards following the same model as Cargo and rustup, with everything in a single directory. Even if Atuin's installer installed to `~/.local/bin`,[^3] this would complicate the setup of `$PATH`. We cannot rely on `~/.local/bin` being in the user's `$PATH`, so the installer, which currently adds `~/.atuin/bin` to `PATH`, would have to add `~/.local/bin` instead if it wasn't already there. But adding `~/.local/bin` is potentially a much larger and less isolated change: if that directory contains binaries but isn't in `$PATH`, adding it injects more than just `atuin` into the user's environment.

[`dirs`]: https://docs.rs/dirs/7
[`cache_dir`]: https://docs.rs/dirs/7/dirs/fn.cache_dir.html
[`config_local_dir`]: https://docs.rs/dirs/7/dirs/fn.config_local_dir.html
[`data_local_dir`]: https://docs.rs/dirs/7/dirs/fn.data_local_dir.html
[cargo-dist]: https://github.com/axodotdev/cargo-dist

[^3]: It should be noted that `~/.local/bin` was added to the XDG Base Directory Specification only in 2021, and unlike every other directory in the specification, cannot be overridden with an environment variable.

<h3 id="migrating-existing-installations">Migrating existing installations</h3>

Migrating existing installations to the new directory layout is not required; they will continue to work as-is. However, if you wish to perform an explicit migration, you can do so by running these commands in Bash, Zsh, or fish (if you have `XDG_CONFIG_HOME` or `XDG_DATA_HOME` set, you will have to adjust the paths).

Temporarily stop the daemon and disable autostart:

```sh
atuin config set daemon.autostart false
atuin daemon stop
```

Make sure `~/.atuin` exists:

```sh
mkdir -p ~/.atuin
```

Check whether you have any AI-related files in `~/.atuin`:

```sh
ls ~/.atuin | grep -Fxe permissions.ai.toml -e skills -e TERMINAL.md
```

If you do, please expand and follow this section:

<details>
  <summary>Migrating existing AI config in <code>~/.atuin</code></summary>

  In older installations, `permissions.ai.toml`, `skills`, or `TERMINAL.md` in `~/.atuin` are scoped to your home directory, whereas in newer installations, they are applied *globally*. The existence of `~/.atuin/config.toml` is what determines whether the new or old behavior is used; if `~/.atuin/config.toml` exists, these items are interpreted as global.

  Before you move your `config.toml` to `~/.atuin`, review the existing AI files in `~/.atuin`, especially `permissions.ai.toml`. Anything you're ok with being made global can be left as-is (global is likely not too different from applying to your entire home directory anyway); anything else should be deleted or moved to subdirectories (meaning moving it into `~/subdir/.atuin/` for each subdirectory).

  After you've confirmed that the AI items in `~/.atuin` can be interpreted as global, check whether any AI items appear in both `~/.atuin` and `~/.config/atuin`:

  ```sh
  ls ~/.atuin ~/.config/atuin | grep -Fxe permissions.ai.toml -e skills \
      -e TERMINAL.md | sort | uniq -d
  ```

  If there are any such items, you must merge the items in `~/.config/atuin` into the configuration at `~/.atuin` if you still want them to take effect; otherwise, the items in `~/.config/atuin` will be shadowed by those in `~/.atuin`.
</details>

Next, move your config items:

```sh
cd ~/.config/atuin && mv -n $(ls | grep -Fxe config.toml -e server.toml \
    -e themes -e skills -e permissions.ai.toml -e TERMINAL.md) ~/.atuin/
```

Then, move your data:

```sh
[ -e ~/.atuin/data ] && echo 'error: destination exists' || \
    mv -n ~/.local/share/atuin ~/.atuin/data
```

Finally, re-enable daemon autostart, if you had it enabled before:

```sh
atuin config set daemon.autostart true
```

### Implementation details

This PR adds a new module, `atuin_common::dirs`, responsible for calculating the paths to all of Atuin's files and directories. See that module for details on how paths are calculated now. A priority of the module is to ensure that existing installations work without modifications, while encouraging new installations to move toward `$ATUIN_HOME`.

`ATUIN_CONFIG_DIR` now officially affects `config.toml` and `server.toml` only. Previously, it affected the location of `config.toml`, `server.toml`, and `themes`, but not `skills`, `permissions.ai.toml`, or `TERMINAL.md`, which ignored the environment variable and were always looked up in `~/.config`. For backward compatibility, if `ATUIN_CONFIG_DIR` is set, `ATUIN_HOME` is unset, and `~/.atuin/themes` does not exist, themes will continue to be read from `$ATUIN_CONFIG_DIR/themes`.

`ATUIN_THEME_DIR` is deprecated. For backward compatibility, if `ATUIN_HOME` is unset and `~/.atuin/themes` does not exist, themes will continue to be read from `$ATUIN_THEME_DIR`.

`data_dir`, `db_path`, `key_path`, and `ai.db_path` are no longer mentioned in the example `config.toml` or the corresponding web documentation. The actual options have not been removed and are still respected. The rationale behind this change is that customizing these paths at that level of granularity is a highly advanced feature that the vast majority of users will not need and that can cause problems; listing them in the example config and documentation (at the very top, no less!) may encourage newer users to modify these settings unnecessarily. Note that several other options of this kind were already undocumented, including `record_store_path`, `kv.db_path`, and `scripts.db_path`; this change unifies all such options to the same treatment of being less visible to new users, but still respected internally.

Fixed a bug where Atuin AI always read snapshots from `~/.local/share/atuin/ai/snapshots` (or the equivalent in `$XDG_DATA_HOME`), ignoring `data_dir` in `config.toml`. It now respects `data_dir`, but for backward compatibility, will still check the old path if `ai/snapshots` doesn't exist in `data_dir`.

Fixed undefined behavior in tests due to the use of `std::env::set_var` and `std::env::remove_var`. A new module, `atuin_common::env`, has been added, which wraps `std::env::var` and `std::env::var_os` but can be mocked safely in tests. There is no performance penalty or behavior difference in normal builds; the mock functionality is compiled in only when `#[cfg(test)]` is true or when the `test-utils` feature is enabled (so it can be used in other crates' tests). Normal builds enable neither of these.

In atuin-client, removed the global `DATA_DIR` variable that got initialized when constructing the first `Settings` instance. This was error-prone and made it hard to ensure that it would be initialized by the time consumers needed it, and obscured the dependency that some code had on `Settings`. Now, `Settings::data_dir` is the one true source to get the path to the data dir.

Similarly to the `data_dir` change, accessing the meta store requires calling the `meta_store` method on a `Settings` instance instead of relying on a global variable that couldn't be guaranteed to be initialized when needed and could panic. As before, only one meta store is constructed per process, even if `meta_store` is called on different `Settings` instances. This matches the existing behavior but could be revisited if necessary.

The behavior of `atuin_client::daemon::socket_path` is different when `ATUIN_HOME` is set. In this case, Atuin will not connect to an existing socket unless it matches the one in the pidfile (this also means the legacy socket paths aren't checked). `ATUIN_HOME` is being introduced after Atuin already switched to storing the socket path in the pidfile; thus, if there's no socket path in the pidfile but the socket exists, the socket is almost certainly that of a different program that we shouldn't connect to (potentially that of an Atuin daemon running with a different value of `ATUIN_HOME`). Similarly, when `ATUIN_HOME` is set, the `--force` flag for `atuin daemon start` will not remove legacy socket paths, since Atuin never creates or connects to those paths in this case.

Atuin AI no longer offers "Always allow in this directory" or "Always allow in this workspace" when the current directory/workspace is `$HOME`, because files like `~/.atuin/permissions.ai.toml` are now interpreted *globally*. It would be possible to re-implement this functionality if necessary, by storing the `$HOME`-scoped files in a different path such as `~/.atuin/home/`, but this has been left out for now as it is not clear how necessary it is: configuration scoped to the user's entire home directory is, for most practical purposes, nearly equivalent to global configuration anyway.

### Important files changed/created

* `crates/atuin-common/src/dirs.rs`: new module containing all the logic for determining which paths to use
* `crates/atuin-daemon/src/client.rs`: new logic for determining the daemon socket path
* `crates/atuin-ai/src/permissions/writer.rs` and `crates/atuin-ai/src/tui/view/mod.rs` have new logic when the current directory or workspace is `$HOME`.
* `crates/atuin-client/src/settings.rs`: cleaned up `Settings` interface; no longer relies on global potentially-uninitialized variables
* `docs/docs/configuration/config.md`: updated to mention `ATUIN_HOME` and provides optional migration instructions
* `crates/atuin-client/src/theme.rs`: cleaned up theme manager logic
* `crates/atuin-common/src/env.rs`: new wrappers around `std::env` to avoid UB in tests
* `crates/atuin-common/src/os/unix/socket.rs`: new *non-blocking* utility for checking whether a socket is in use

### Limitations

Note that even with this change, the install script will still create `~/.config/atuin/atuin-receipt.json` (or `%LOCALAPPDATA\atuin\atuin-receipt.json` on Windows). This path is hardcoded by [cargo-dist]. To change this, cargo-dist and axoupdater would need to add support for customizing the receipt path, or we would need to fork those crates, or we would need to switch to a different installer and updater.

### To-do before merge

- [x] Smoke testing:
  - [x] Existing installs continue to work
  - [x] Fresh installs work and go in `~/.atuin`
  - [x] `ATUIN_HOME` works, including multiple profiles simultaneously
  - [x] Migration instructions work
  - [x] Partially migrated installs work (e.g., `data` only)
  - [x] New Atuin AI behavior for `$HOME` works
This commit is contained in:
taylor.fish authored and taylor.fish committed 2026-10-13 22:14:22 +00:00
1 parent ae3b9d1187
commit 8359eb0b31
162 files changed
+2760 -893

No files matched your search

+2 -2
View File
@@ -20,8 +20,8 @@ ln -sf "$USER_HOME/.atuin/bin/atuin" /usr/local/bin/atuin
# sandbox-safe config: fixed daemon socket, since pods have no logind
# session and therefore no XDG_RUNTIME_DIR.
mkdir -p "$USER_HOME/.config/atuin"
cat >"$USER_HOME/.config/atuin/config.toml" <<TOML
mkdir -p "$USER_HOME/.atuin"
cat >"$USER_HOME/.atuin/config.toml" <<TOML
$([ -n "${SYNCADDRESS}" ] && echo "sync_address = \"${SYNCADDRESS}\"")
[daemon]
+2 -2
View File
@@ -21,12 +21,12 @@ atuin-server-sqlite SQLite implementation (sqlx)
## Two sync protocols
- **V1 (legacy)**: Syncs history entries directly. Being phased out. Toggleable via `sync_v1_enabled`.
- **V1 (legacy)**: Syncs history entries directly. Removed.
- **V2 (current)**: Record store abstraction. All data types (history, KV, aliases, vars, scripts) share the same sync infrastructure using tagged records. Envelope-encrypted with PASETO V4 and per-record CEKs.
## Encryption
- **V1**: XSalsa20Poly1305 (secretbox). Key at `~/.local/share/atuin/key`.
- **V1**: XSalsa20Poly1305 (secretbox). Key at `~/.atuin/data/key`.
- **V2**: PASETO V4 Local (XChaCha20-Poly1305 + Blake2b). Envelope encryption: each record gets a random CEK wrapped with the master key. Record metadata (id, idx, version, tag, host) is authenticated as implicit assertions.
## Databases
+5 -3
View File
@@ -22,7 +22,7 @@ One side effect: a bulk `cargo update` bumps `locked-tripwire` past its pinned v
cargo update -p locked-tripwire --precise 0.1.1
```
Before working on anything, we suggest taking a copy of your Atuin data directory (`~/.local/share/atuin` on most \*nix platforms). If anything goes wrong, you can always restore it!
Before working on anything, we suggest taking a copy of your Atuin data directory (`~/.atuin/data`, or `~/.local/share/atuin` if created by an older version). If anything goes wrong, you can always restore it!
While data directory backups are always a good idea, you can instruct Atuin to use custom path using the following environment variables:
@@ -41,12 +41,14 @@ It is also recommended to update your `$PATH` so that the pre-exec scripts would
export PATH="./target/release:$PATH"
```
If you'd like to load a different configuration file, set `ATUIN_CONFIG_DIR` to a folder that contains your `config.toml` file:
You can also use an entirely separate profile by setting `ATUIN_HOME` to something other than the default `~/.atuin`:
```shell
export ATUIN_CONFIG_DIR=/tmp/atuin-config/
export ATUIN_HOME=/tmp/atuin-home/
```
This way, Atuin will read everything from `$ATUIN_HOME`, and won't touch your existing installation. If you do this, make sure you set an explicit `daemon.socket_path` in the new profile's `config.toml`, to avoid connecting to the wrong daemon.
These variable exports can be added in a local `.envrc` file, read by [direnv](https://direnv.net/).
## Editor setup (optional)
+1 -1
View File
@@ -127,7 +127,7 @@ runtime-format = "0.1"
# bundled SQLite), with unlock_notify as sqlx has it, for shared-cache in-memory databases.
rusqlite = { version = "0.32", features = ["unlock_notify"] }
rust-embed = "8"
rustix = { version = "1", features = ["process", "fs", "pty", "termios", "stdio", "event"] }
rustix = { version = "1", features = ["event", "fs", "net", "process", "pty", "stdio", "termios"] }
schemars = "1"
secrecy = { version = "0.10", features = ["serde"] }
rusty_paserk = { version = "0.5", default-features = false, features = ["v4", "serde"] }
+16
View File
@@ -11,6 +11,22 @@ reason = "panics on overflow; use atuin_common::time::DurationExt::try_new"
path = "std::time::Duration::from_secs_f64"
reason = "panics on NaN/negative/overflow; use Duration::try_from_secs_f64"
[[disallowed-methods]]
path = "std::env::var"
reason = "use atuin_common::env::var, which can be mocked by tests"
[[disallowed-methods]]
path = "std::env::var_os"
reason = "use atuin_common::env::var_os, which can be mocked by tests"
[[disallowed-methods]]
path = "std::env::set_var"
reason = "unsound; in tests, use atuin_common::env::MockEnv"
[[disallowed-methods]]
path = "std::env::remove_var"
reason = "unsound; in tests, use atuin_common::env::MockEnv"
[[disallowed-types]]
path = "std::sync::Mutex"
reason = "use parking_lot::Mutex which is faster"
+1
View File
@@ -84,6 +84,7 @@ arboard = { workspace = true, optional = true }
arboard = { workspace = true, optional = true, features = ["wayland-data-control"] }
[dev-dependencies]
atuin-common = { workspace = true, features = ["test-utils"] }
eye_declare_engine = { workspace = true }
proptest = { workspace = true }
rstest = { workspace = true }
+37 -6
View File
@@ -1,4 +1,7 @@
use std::path::PathBuf;
use atuin_client::database::Sqlite;
use atuin_common::dirs;
use easy_cast::Conv;
use eyre::{Context as _, Result, bail};
use secrecy::{ExposeSecret as _, SecretString};
@@ -98,7 +101,7 @@ pub async fn run(
}
async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Result<SecretString> {
if let Some(token) = atuin_client::hub::get_session_token().await? {
if let Some(token) = atuin_client::hub::get_session_token(settings).await? {
debug!("Found Hub session, using existing token");
return Ok(token);
}
@@ -145,13 +148,13 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
.await?;
info!("Authentication complete, saving session token");
atuin_client::hub::save_session(&token).await?;
atuin_client::hub::save_session(&token, settings).await?;
if let Ok(meta) = atuin_client::settings::Settings::meta_store().await
if let Ok(meta) = settings.meta_store().await
&& let Ok(Some(cli_token)) = meta.session_token().await
{
debug!("CLI session found, attempting to link accounts");
if let Err(e) = atuin_client::hub::link_account(&hub_address, &cli_token).await {
if let Err(e) = atuin_client::hub::link_account(&hub_address, &cli_token, settings).await {
debug!("Could not link CLI account to Hub: {}", e);
} else {
info!("Successfully linked CLI account to Hub");
@@ -163,6 +166,35 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
// ───────────────────────────────────────────────────────────────────
/// The directory where AI snapshots are stored.
///
/// Older versions of Atuin stored snapshots in the [legacy data dir] even when [`data_dir`] was set
/// in config.toml. If the new snapshot dir doesn't exist, this function will check the legacy path
/// and return it if it exists, unless `ATUIN_HOME` is set.
///
/// [legacy data dir]: dirs::legacy_data_dir
/// [`data_dir`]: field@atuin_client::settings::Settings::data_dir
fn snapshots_dir(settings: &atuin_client::settings::Settings) -> PathBuf {
fn push_snapshots(path: &mut PathBuf) {
path.push("ai");
path.push("snapshots");
}
let mut path = settings.data_dir.clone();
push_snapshots(&mut path);
if !path.exists() && !dirs::atuin_home_is_set() {
// Check the legacy path, but only if `ATUIN_HOME` isn't set.
let mut legacy = dirs::legacy_data_dir();
push_snapshots(&mut legacy);
if legacy.exists() {
return legacy;
}
}
path
}
async fn run_inline_tui(
ctx: AppContext,
initial_prompt: Option<String>,
@@ -279,8 +311,7 @@ async fn run_inline_tui(
settings.ai.model.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
// ─── Snapshot store ─────────────────────────────────────────
let snapshot_dir =
atuin_common::utils::data_dir().join("ai").join("snapshots").join(session_mgr.session_id());
let snapshot_dir = snapshots_dir(settings).join(session_mgr.session_id());
let snapshot_store = crate::snapshots::SnapshotStore::open(snapshot_dir).ok();
// ─── Discover skills ───────────────────────────────────────
+5 -6
View File
@@ -203,7 +203,7 @@ impl Cmd {
fn query(&self) -> String {
if self.query.is_empty() {
std::env::var("ATUIN_QUERY").unwrap_or_default()
atuin_common::env::var("ATUIN_QUERY").unwrap_or_default()
} else {
self.query.join(" ")
}
@@ -425,8 +425,8 @@ pub async fn run(cmd: Cmd, settings: &Settings) -> Result<()> {
let output = cmd.output();
let query = cmd.query();
let context = ResumeContext::current().await?;
let path = Settings::ai_session_sidecar_path();
let context = ResumeContext::current(&settings).await?;
let path = settings.ai_session_sidecar_path();
// The daemon makes it the first time it runs with sessions on.
if !path.exists() {
if settings.ai.capture_sessions {
@@ -435,8 +435,7 @@ pub async fn run(cmd: Cmd, settings: &Settings) -> Result<()> {
bail!("no AI sessions here yet: turn them on with `atuin config enable ai-sessions`");
}
let source: Arc<dyn SessionSource> = Arc::new(SidecarSource::open(&path, &context).await?);
let resumer: Arc<dyn Resumer> =
Arc::new(HarnessResumer::new(context.clone(), settings.ai.sessions.resume.clone()));
let resumer: Arc<dyn Resumer> = Arc::new(HarnessResumer::new(context.clone(), &settings));
if cmd.fork || cmd.continue_in.is_some() {
let (source, resumer, query) = (source.as_ref(), resumer.as_ref(), query.trim());
@@ -502,7 +501,7 @@ pub async fn run(cmd: Cmd, settings: &Settings) -> Result<()> {
resume <id>` (`atuin ai session list` lists them)"
);
}
let mut themes = ThemeManager::new(settings.theme.debug, None);
let mut themes = ThemeManager::from_settings(&settings.theme);
let theme = themes.load_theme(settings.theme.name.as_str(), settings.theme.max_depth);
let (outcome, note) = Picker {
settings: &settings,
+1 -1
View File
@@ -355,7 +355,7 @@ async fn tail(client: &mut AiClient, style: Style) -> Result<()> {
let mut active: Option<HarnessSession> = None;
// Color only in the pretty (terminal) view, and never when NO_COLOR is set.
let color = matches!(style, Style::Pretty) && std::env::var_os("NO_COLOR").is_none();
let color = matches!(style, Style::Pretty) && atuin_common::env::var_os("NO_COLOR").is_none();
while let Some(event) = stream.next().await {
let Some(event) = event?.event else {
+1 -1
View File
@@ -74,7 +74,7 @@ pub fn capability_strings(capabilities: &AiCapabilities, daemon_enabled: bool) -
{
caps.push(cap(descriptor::ATUIN_OUTPUT));
}
if let Ok(extra) = std::env::var("ATUIN_AI__ADDITIONAL_CAPS") {
if let Ok(extra) = atuin_common::env::var("ATUIN_AI__ADDITIONAL_CAPS") {
caps.extend(extra.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
}
caps
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::tools::ClientToolCall;
pub enum PermissionTarget {
/// Project-level: `<git_root_or_cwd>/.atuin/permissions.ai.toml`
Project,
/// Global: `~/.config/atuin/permissions.ai.toml`
/// Global: `~/.atuin/permissions.ai.toml`
Global,
}
+1 -1
View File
@@ -116,7 +116,7 @@ impl ServerHandler for AtuinMcp {
// wrong and can retry (MCP SEP-1303); many clients never show protocol errors to it.
let outcome = match request.name.as_ref() {
"atuin_history" => match AtuinHistoryToolCall::try_from(&Value::Object(arguments)) {
Ok(call) => call.execute(&self.db).await,
Ok(call) => call.execute(&self.db, &self.settings).await,
Err(e) => invalid_arguments(e),
},
"atuin_output" => match AtuinOutputToolCall::try_from(&Value::Object(arguments)) {
+48 -8
View File
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use atuin_common::path::PathExt as _;
use eyre::Result;
use tokio::task::JoinSet;
@@ -13,7 +14,7 @@ struct FoundRuleFile {
pub struct PermissionWalker {
start: PathBuf,
/// Direct path to the global permissions file (e.g. `~/.config/atuin/permissions.ai.toml`).
/// Direct path to the global permissions file (e.g. `~/.atuin/permissions.ai.toml`).
global_permissions_file: Option<PathBuf>,
rules: Vec<RuleFile>,
}
@@ -40,8 +41,17 @@ impl PermissionWalker {
let mut set: JoinSet<Result<Option<FoundRuleFile>>> = JoinSet::new();
for (index, path) in dirs_to_check.into_iter().enumerate() {
let permissions = super::writer::project_permissions_path(&path);
if let Some(global) = &self.global_permissions_file
&& permissions.is_same_path(global)
{
// Avoid loading ~/.atuin/permissions.ai.toml twice.
continue;
}
set.spawn(async move {
match check_dir_for_permissions(&path).await {
match load_permissions_file(&permissions).await {
Ok(Some(rule_file)) => Ok(Some(FoundRuleFile {
depth: index,
file: rule_file,
@@ -93,12 +103,6 @@ impl PermissionWalker {
}
}
/// Checks a directory for `.atuin/permissions.ai.toml` and returns the RuleFile if found.
async fn check_dir_for_permissions(path: &Path) -> Result<Option<RuleFile>> {
let file_path = path.join(".atuin").join("permissions.ai.toml");
load_permissions_file(&file_path).await
}
/// Load a permissions file from an exact path. Returns None if the file doesn't exist.
async fn load_permissions_file(file_path: &Path) -> Result<Option<RuleFile>> {
if !tokio::fs::try_exists(file_path).await? {
@@ -113,3 +117,39 @@ async fn load_permissions_file(file_path: &Path) -> Result<Option<RuleFile>> {
Ok(Some(RuleFile { path, content }))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
/// A `.atuin/permissions.ai.toml` above the working directory that is also the global file,
/// as `~/.atuin/permissions.ai.toml` is by default, is loaded once. Any other is a project's,
/// and is still loaded alongside the global one.
#[rstest]
#[case::global(true, 1)]
#[case::project(false, 2)]
#[tokio::test]
async fn the_global_file_is_not_also_a_project_file(
#[case] is_global: bool,
#[case] expected: usize,
) {
let home = tempfile::tempdir().unwrap();
let dotfile = home.path().join(".atuin").join("permissions.ai.toml");
let elsewhere = home.path().join("elsewhere").join("permissions.ai.toml");
for file in [&dotfile, &elsewhere] {
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, "[permissions]\nallow = []\n").unwrap();
}
let global = if is_global {
dotfile
} else {
elsewhere
};
let mut walker = PermissionWalker::new(home.path().to_path_buf(), Some(global));
walker.walk().await.unwrap();
assert_eq!(walker.rules().len(), expected);
}
}
+110 -8
View File
@@ -1,5 +1,6 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use atuin_common::path::PathExt as _;
use eyre::Result;
use crate::permissions::rule::Rule;
@@ -72,25 +73,126 @@ pub async fn write_rule(file_path: &Path, rule: &Rule, disposition: RuleDisposit
Ok(())
}
/// Controls the scope of "always allow" for a given directory.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProjectScope {
/// The Git repository the directory is in.
Workspace,
/// The directory itself.
Directory,
/// The directory is `$HOME`, so project-level scope is unavailable, as it would conflict with
/// `~/.atuin` being used for global permissions.
Home,
}
impl ProjectScope {
/// The scope for the current directory.
///
/// `git_root` is the root of the Git repository the current directory is in, if any.
pub fn detect(git_root: Option<&Path>) -> Self {
Self::detect_with(git_root, || std::env::current_dir().ok())
}
fn detect_with(git_root: Option<&Path>, get_cwd: impl FnOnce() -> Option<PathBuf>) -> Self {
let cwd;
let root = if let Some(root) = git_root {
Some(root)
} else {
cwd = get_cwd();
cwd.as_deref()
};
if let Some(root) = root
&& atuin_common::dirs::try_home_dir().is_ok_and(|home| root.is_same_path(home))
{
return Self::Home;
}
if git_root.is_some() {
Self::Workspace
} else {
Self::Directory
}
}
}
/// Build the path to the project-level permissions file.
/// `project_root` is typically a git root or the current working directory.
pub fn project_permissions_path(project_root: &Path) -> std::path::PathBuf {
project_root.join(".atuin").join("permissions.ai.toml")
pub fn project_permissions_path(project_root: &Path) -> PathBuf {
PathBuf::from_iter([project_root, ".atuin".as_ref(), "permissions.ai.toml".as_ref()])
}
/// Build the path to the global permissions file (sibling of atuin config).
pub fn global_permissions_path() -> std::path::PathBuf {
atuin_common::utils::config_dir().join("permissions.ai.toml")
pub fn global_permissions_path() -> PathBuf {
atuin_common::dirs::config_path("permissions.ai.toml")
}
#[cfg(test)]
mod tests {
use atuin_common::env::MockEnv;
use rstest::*;
use super::*;
/// A mock environment, in which every variable (including `HOME`) starts out unset.
#[fixture]
fn perm_file() -> (tempfile::TempDir, std::path::PathBuf) {
fn env() -> MockEnv {
MockEnv::install()
}
#[rstest]
#[case::git_repository(
Some("/home/me/src/atuin"),
"/home/me/src/atuin/crates",
ProjectScope::Workspace
)]
#[case::plain_directory(None, "/home/me/notes", ProjectScope::Directory)]
#[case::outside_home(None, "/tmp", ProjectScope::Directory)]
#[case::home(None, "/home/me", ProjectScope::Home)]
// Dotfiles kept in a git repository rooted at the home directory.
#[case::home_as_git_repository(Some("/home/me"), "/home/me/.config", ProjectScope::Home)]
#[case::subdirectory_of_home(None, "/home/me/.config", ProjectScope::Directory)]
fn project_scope_is_never_the_home_directory(
env: MockEnv,
#[case] git_root: Option<&str>,
#[case] cwd: &str,
#[case] expected: ProjectScope,
) {
env.set("HOME", "/home/me");
let scope = ProjectScope::detect_with(git_root.map(Path::new), || {
assert!(git_root.is_none(), "the working directory isn't needed in a git repository");
Some(PathBuf::from(cwd))
});
assert_eq!(scope, expected);
}
/// Without a home directory or a working directory to compare, the project is just a
/// directory.
#[rstest]
fn project_scope_without_a_known_home_or_cwd(env: MockEnv) {
assert_eq!(
ProjectScope::detect_with(None, || Some(PathBuf::from("/home/me"))),
ProjectScope::Directory
);
env.set("HOME", "/home/me");
assert_eq!(ProjectScope::detect_with(None, || None), ProjectScope::Directory);
}
/// A home directory reached through a symlink is still the home directory.
#[cfg(unix)]
#[rstest]
fn project_scope_sees_through_symlinks(env: MockEnv) {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let link = dir.path().join("link");
std::fs::create_dir(&home).unwrap();
std::os::unix::fs::symlink(&home, &link).unwrap();
env.set("HOME", &home);
assert_eq!(ProjectScope::detect_with(None, || Some(link)), ProjectScope::Home);
}
#[fixture]
fn perm_file() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("permissions.ai.toml");
(dir, file)
@@ -113,7 +215,7 @@ mod tests {
#[case::deny(None, "Shell", RuleDisposition::Deny, &["deny", r#""Shell""#])]
#[tokio::test]
async fn writes_expected_content(
#[from(perm_file)] (_dir, file): (tempfile::TempDir, std::path::PathBuf),
#[from(perm_file)] (_dir, file): (tempfile::TempDir, PathBuf),
#[case] initial: Option<&str>,
#[case] tool: &str,
#[case] disposition: RuleDisposition,
@@ -138,7 +240,7 @@ mod tests {
#[rstest]
#[tokio::test]
async fn does_not_duplicate_existing_rule(
#[from(perm_file)] (_dir, file): (tempfile::TempDir, std::path::PathBuf),
#[from(perm_file)] (_dir, file): (tempfile::TempDir, PathBuf),
) {
let existing = r#"[permissions]
allow = ["AtuinHistory"]
+3 -3
View File
@@ -75,8 +75,8 @@ pub struct ResumeContext {
impl ResumeContext {
/// The current directory, repository, branch and host.
pub async fn current() -> Result<Self> {
let ctx = atuin_client::database::query_context().await?;
pub async fn current(settings: &Settings) -> Result<Self> {
let ctx = atuin_client::database::query_context(settings).await?;
// `$PWD` as it is set, which may end in a separator: rebuilt from its components, so a
// session restored or continued here isn't written with `…/dir/` as its directory.
let cwd: PathBuf = Path::new(&ctx.cwd).components().collect();
@@ -1015,7 +1015,7 @@ impl Picker<'_> {
fn set_clipboard(s: &str) -> Result<()> {
// Over SSH the system clipboard is the remote machine's, if it has one: the terminal's is
// the one wanted.
if std::env::var_os("SSH_CONNECTION").is_some() {
if atuin_common::env::var_os("SSH_CONNECTION").is_some() {
return Ok(terminal::copy(s)?);
}
let system = || -> Result<()> {
+5 -1
View File
@@ -517,7 +517,11 @@ mod tests {
#[case] host_id: &str,
#[case] want: &str,
) {
let mut themes = atuin_client::theme::ThemeManager::new(None, None);
let mut themes = atuin_client::theme::ThemeManagerOptions {
debug: false,
builtin_only: true,
}
.build();
let theme = themes.load_theme("default", None);
let mut row = row();
row.git_root = Some("/src/atuin".into());
+12 -18
View File
@@ -19,7 +19,8 @@ use std::path::{Path, PathBuf};
use async_trait::async_trait;
use atuin_client::ai_session::{HarnessKind, Head, SourceId};
use atuin_client::settings::AiSessionResume;
use atuin_client::settings::{AiSessionResume, Settings};
use atuin_common::env;
use atuin_common::harnesstools::continuation::{self, Flattened};
use atuin_common::harnesstools::rehydrate::{
ForkOf, RehydrateError, RehydrateMessage, RehydrateSession,
@@ -442,35 +443,28 @@ pub struct HarnessResumer {
/// Where copies switched to another branch are kept as they were: `ai/switched` in atuin's data
/// directory (`data_dir`, `ATUIN_DATA_DIR`), which no agent lists sessions from.
pub fn switched_dir() -> PathBuf {
atuin_client::settings::Settings::effective_data_dir().join("ai").join("switched")
pub fn switched_dir(settings: &Settings) -> PathBuf {
settings.data_dir.join("ai").join("switched")
}
impl HarnessResumer {
pub fn new(context: ResumeContext, templates: AiSessionResume) -> Self {
Self::on(context, templates, ThisMachine)
}
/// The resumer, keeping copies switched to another branch in `dir` instead of
/// [`switched_dir`].
#[cfg(test)]
#[must_use]
pub fn keeping_switched_in(mut self, dir: impl Into<PathBuf>) -> Self {
self.switched = dir.into();
self
pub fn new(context: ResumeContext, settings: &Settings) -> Self {
let templates = settings.ai.sessions.resume.clone();
Self::on(context, templates, ThisMachine, switched_dir(settings))
}
/// A resumer looking at `machine` instead of this one.
pub fn on(
fn on(
context: ResumeContext,
templates: AiSessionResume,
machine: impl Machine + 'static,
switched: impl Into<PathBuf>,
) -> Self {
Self {
templates,
context,
machine: Box::new(machine),
switched: switched_dir(),
switched: switched.into(),
}
}
@@ -1054,7 +1048,7 @@ pub fn find_program(program: &str) -> Option<PathBuf> {
if program.contains(std::path::MAIN_SEPARATOR) || program.contains('/') {
return runnable(Path::new(program));
}
let paths = std::env::var_os("PATH")?;
let paths = env::var_os("PATH")?;
std::env::split_paths(&paths).find_map(|dir| runnable(&dir.join(program)))
}
@@ -1086,7 +1080,7 @@ fn runnable(path: &Path) -> Option<PathBuf> {
if path.is_file() {
return Some(path.to_path_buf());
}
let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
let extensions = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
extensions.split(';').filter(|ext| !ext.is_empty()).find_map(|ext| {
let mut with_ext = path.as_os_str().to_owned();
with_ext.push(ext);
@@ -247,8 +247,7 @@ fn context(dirs: &Dirs) -> ResumeContext {
}
fn resumer(dirs: &Dirs, machine: &FakeMachine) -> HarnessResumer {
HarnessResumer::on(context(dirs), AiSessionResume::default(), machine.clone())
.keeping_switched_in(&dirs.switched)
HarnessResumer::on(context(dirs), AiSessionResume::default(), machine.clone(), &dirs.switched)
}
fn row(harness: HarnessKind, id: &str, cwd: &Path, remote: bool) -> SessionRow {
@@ -374,7 +373,7 @@ async fn a_path_template_plans_a_restore_and_resumes_the_restored_transcript(dir
pi: Some("my-pi --session {path}".to_owned()),
..AiSessionResume::default()
};
let resumer = HarnessResumer::on(context(&dirs), templates, machine.clone());
let resumer = HarnessResumer::on(context(&dirs), templates, machine.clone(), &dirs.switched);
let remote = row(HarnessKind::Pi, "abc-123", Path::new("/gone/proj"), true);
let resume = resumer.plan(&remote).await.unwrap();
@@ -650,7 +649,7 @@ async fn a_path_template_target_is_offered_and_resumes_the_written_session(dirs:
pi: Some("pi --session {path}".to_owned()),
..AiSessionResume::default()
};
let resumer = HarnessResumer::on(context(&dirs), templates, machine.clone());
let resumer = HarnessResumer::on(context(&dirs), templates, machine.clone(), &dirs.switched);
let session = row(HarnessKind::ClaudeCode, "abc-123", &dirs.elsewhere, true);
assert!(resumer.continue_targets(&session).contains(&HarnessKind::Pi));
@@ -708,6 +707,8 @@ fn finds_programs_on_path_and_by_path_on_unix() {
#[cfg(windows)]
#[rstest]
fn finds_programs_on_path_and_by_path_on_windows() {
use atuin_common::env;
// `cmd` is found as `cmd.exe` through PATHEXT, on PATH or by its path.
assert!(on_path("cmd"));
assert!(on_path("cmd.exe"));
@@ -715,7 +716,7 @@ fn finds_programs_on_path_and_by_path_on_windows() {
// resolve to were it a `.cmd` or `.bat` script.
let found = find_program("cmd").unwrap();
assert!(found.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("exe")), "{found:?}");
let system32 = std::path::Path::new(&std::env::var("SystemRoot").unwrap()).join("System32");
let system32 = std::path::Path::new(&env::var("SystemRoot").unwrap()).join("System32");
assert!(on_path(system32.join("cmd").to_str().unwrap()));
assert!(on_path(system32.join("cmd.exe").to_str().unwrap()));
assert!(!on_path(r"C:\nonexistent\cmd"));
@@ -755,7 +756,7 @@ async fn a_relative_template_program_is_found_from_the_sessions_directory(dirs:
claude: Some("./bin/wrapper --resume {id}".to_owned()),
..AiSessionResume::default()
};
let resumer = HarnessResumer::on(context(&dirs), templates, machine);
let resumer = HarnessResumer::on(context(&dirs), templates, machine, &dirs.switched);
let session = row(HarnessKind::ClaudeCode, "abc-123", &dirs.elsewhere, false);
// Not in the session's directory: not installed, whatever the picker's own directory has.
@@ -829,7 +830,8 @@ async fn a_fork_is_a_new_session_of_its_own_harness(dirs: Dirs) {
missing_programs: vec!["claude"],
..FakeMachine::default()
};
let without = HarnessResumer::on(context(&dirs), AiSessionResume::default(), missing);
let without =
HarnessResumer::on(context(&dirs), AiSessionResume::default(), missing, &dirs.switched);
assert!(!without.can_fork(&session));
}
+16 -4
View File
@@ -1,7 +1,7 @@
//! Rendering tests: the picker drawn from the fake source into ratatui's `TestBackend`.
use atuin_client::settings::{KeymapMode, Settings, Style as UiStyle};
use atuin_client::theme::ThemeManager;
use atuin_client::theme::ThemeManagerOptions;
use atuin_client::tui::cursor::Cursor;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -46,7 +46,11 @@ async fn loaded(settings: &Settings, query: &str, tab: usize) -> State {
}
fn render(state: &mut State, settings: &Settings, width: u16, height: u16) -> Buffer {
let mut themes = ThemeManager::new(None, None);
let mut themes = ThemeManagerOptions {
debug: false,
builtin_only: true,
}
.build();
let theme = themes.load_theme("default", None);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|f| state.draw(f, settings, theme)).unwrap();
@@ -1563,7 +1567,11 @@ fn a_continuation_in_the_tree_says_where_it_went_on() {
let mut child = fake::row(HarnessKind::Codex, "0199aaaa", "fix the flaky test");
child.parent = Some(root.handle.clone());
child.relation = Relation::Fork;
let mut themes = ThemeManager::new(None, None);
let mut themes = ThemeManagerOptions {
debug: false,
builtin_only: true,
}
.build();
let theme = themes.load_theme("default", None);
let lines = super::panel::tree_lines(
&root.handle,
@@ -1860,7 +1868,11 @@ async fn the_wheel_scrolls_the_pane_under_it(#[case] origin: u16) {
let s = settings();
let mut state = loaded(&s, "", 0).await;
let mut themes = ThemeManager::new(None, None);
let mut themes = ThemeManagerOptions {
debug: false,
builtin_only: true,
}
.build();
let theme = themes.load_theme("default", None);
let mut backend = TestBackend::new(100, 42);
backend.set_cursor_position((0, origin)).unwrap();
+45 -5
View File
@@ -6,6 +6,8 @@
use std::path::{Path, PathBuf};
use atuin_common::path::PathExt as _;
const SKILL_FILENAME: &str = "SKILL.md";
/// A skill file found on disk, before body interpolation.
@@ -32,8 +34,11 @@ pub async fn discover(
) -> Vec<RawSkillFile> {
let mut files = Vec::new();
// Project skills first (higher priority)
if let Some(dir) = project_skills_dir.filter(|d| d.is_dir()) {
// Project skills first (higher priority). Filter out `global_skills_dir` so we don't load it
// twice (in case it's ~/.atuin).
if let Some(dir) =
project_skills_dir.filter(|d| d.is_dir() && !d.is_same_path(global_skills_dir))
{
scan_dir(dir, true, &mut files).await;
}
@@ -45,14 +50,14 @@ pub async fn discover(
files
}
/// The default global skills directory (`~/.config/atuin/skills/`).
/// The default global skills directory (`~/.atuin/skills/`).
pub fn global_skills_dir() -> PathBuf {
atuin_common::utils::config_dir().join("skills")
atuin_common::dirs::config_path("skills")
}
/// Given a project working directory, return the project skills directory.
pub fn project_skills_dir(project_root: &Path) -> PathBuf {
project_root.join(".atuin").join("skills")
PathBuf::from_iter([project_root, ".atuin".as_ref(), "skills".as_ref()])
}
/// Recursively scan a directory for `SKILL.md` files.
@@ -104,6 +109,8 @@ async fn scan_dir(dir: &Path, is_project: bool, out: &mut Vec<RawSkillFile>) {
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn setup_skill(dir: &Path, rel_path: &str, content: &str) {
@@ -112,6 +119,7 @@ mod tests {
std::fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap();
}
#[rstest]
#[tokio::test]
async fn discovers_project_skills() {
let dir = tempfile::tempdir().unwrap();
@@ -124,6 +132,7 @@ mod tests {
assert!(files[0].is_project);
}
#[rstest]
#[tokio::test]
async fn discovers_global_skills() {
let dir = tempfile::tempdir().unwrap();
@@ -136,6 +145,7 @@ mod tests {
assert!(!files[0].is_project);
}
#[rstest]
#[tokio::test]
async fn discovers_nested_skills() {
let dir = tempfile::tempdir().unwrap();
@@ -147,6 +157,7 @@ mod tests {
assert_eq!(files.len(), 2);
}
#[rstest]
#[tokio::test]
async fn project_comes_before_global() {
let project = tempfile::tempdir().unwrap();
@@ -163,9 +174,38 @@ mod tests {
assert!(!files[1].is_project);
}
#[rstest]
#[tokio::test]
async fn missing_directories_handled() {
let files = discover(Some(Path::new("/does/not/exist")), Path::new("/also/missing")).await;
assert!(files.is_empty());
}
/// A project skills directory that is also the global one, as `~/.atuin/skills` is by
/// default, is scanned once, as the global one.
#[rstest]
#[case::global(true, 1, false)]
#[case::project(false, 2, true)]
#[tokio::test]
async fn the_global_dir_is_not_also_a_project_dir(
#[case] is_global: bool,
#[case] expected: usize,
#[case] first_is_project: bool,
) {
let home = tempfile::tempdir().unwrap();
let project = home.path().join(".atuin").join("skills");
let elsewhere = home.path().join("elsewhere").join("skills");
for dir in [&project, &elsewhere] {
setup_skill(dir, "deploy", "---\nname: deploy\n---\nDeploy.");
}
let global = if is_global {
&project
} else {
&elsewhere
};
let files = discover(Some(&project), global).await;
assert_eq!(files.len(), expected);
assert_eq!(files[0].is_project, first_is_project);
}
}
+5 -2
View File
@@ -2,8 +2,10 @@
// SSE streaming
// ───────────────────────────────────────────────────────────────────
use std::sync::Arc;
use atuin_client::history::History;
use atuin_client::settings::AiCapabilities;
use atuin_client::settings::{AiCapabilities, Settings};
use atuin_common::url::UrlAppendExt;
use easy_cast::Conv;
use eventsource_stream::Eventsource;
@@ -99,6 +101,7 @@ pub fn create_chat_stream(
user_contexts: Vec<crate::user_context::UserContext>,
skill_summaries: Vec<crate::skills::SkillSummary>,
skill_overflow: Option<String>,
settings: Arc<Settings>,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<StreamFrame>> + Send>> {
Box::pin(async_stream::stream! {
let endpoint = match hub_address.append_path("api/cli/chat") {
@@ -165,7 +168,7 @@ pub fn create_chat_stream(
if status == reqwest::StatusCode::UNAUTHORIZED {
if token_from_hub_session {
tracing::error!("SSE request failed with status: {status}, clearing session");
let _ = atuin_client::hub::delete_session().await;
let _ = atuin_client::hub::delete_session(&settings).await;
yield Err(eyre::eyre!("Hub session expired. Re-run to authenticate again."));
} else if token.is_none() {
tracing::error!("SSE request failed with status: {status}");
+6 -2
View File
@@ -1111,12 +1111,16 @@ impl PermissibleToolCall for AtuinHistoryToolCall {
}
impl AtuinHistoryToolCall {
pub(crate) async fn execute(&self, db: &atuin_client::database::Sqlite) -> ToolOutcome {
pub(crate) async fn execute(
&self,
db: &atuin_client::database::Sqlite,
settings: &atuin_client::settings::Settings,
) -> ToolOutcome {
use atuin_client::database::{self, DbSearchMode, OptFilters};
// query_context rather than current_context: when running outside an
// atuin-hooked shell (e.g. as an MCP server) there is no ATUIN_SESSION.
let context = match database::query_context().await {
let context = match database::query_context(settings).await {
Ok(ctx) => ctx,
Err(e) => return ToolOutcome::Error(format!("Failed to get history context: {e}")),
};
+2 -1
View File
@@ -5,6 +5,7 @@ use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use atuin_client::ai_session::{HarnessKind, HarnessSession, NativeSessionId, Session};
use atuin_common::env;
use atuin_daemon::AiClient;
use time::OffsetDateTime;
@@ -82,7 +83,7 @@ impl Caller<'_> {
fn env_session(&self) -> Option<HarnessSession> {
let harness = self.harness().filter(|h| *h == HarnessKind::ClaudeCode)?;
let id = std::env::var("CLAUDE_CODE_SESSION_ID").ok().filter(|id| !id.is_empty())?;
let id = env::var("CLAUDE_CODE_SESSION_ID").ok().filter(|id| !id.is_empty())?;
Some(HarnessSession {
harness,
session: NativeSessionId::from(id),
+17 -12
View File
@@ -4,6 +4,7 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use atuin_client::settings::Settings;
@@ -20,6 +21,7 @@ use crate::fsm::effects::{Effect, ExitAction, PermissionTarget, TimeoutKind};
use crate::fsm::events::{Event, PermissionChoice, PermissionResponse};
use crate::fsm::tools::ToolPreviewData;
use crate::fsm::{AgentFsm, AgentState, StreamPhase};
use crate::permissions::writer::ProjectScope;
use crate::tools::{ClientToolCall, PermissibleToolCall};
use crate::tui::events::PermissionResult;
use crate::tui::persist::PersistJob;
@@ -113,7 +115,8 @@ pub struct AiApp {
permission_prompt_for: Option<String>,
/// Cursor for the /model picker, reset whenever the picker closes.
model_select: SelectState,
in_git_project: bool,
/// Controls the scope of "always allow" (e.g., git root vs current directory).
project_scope: ProjectScope,
/// Submitted by `init` as if typed (the `atuin ai "question"` path).
initial_prompt: Option<String>,
/// Whether `init` should refresh usage in the background.
@@ -141,7 +144,7 @@ pub struct AiApp {
pushed_turns: usize,
exiting: bool,
/// Config snapshot from startup; tip relevance predicates read it.
settings: Settings,
settings: Arc<Settings>,
tips: TipRotation,
/// When the in-flight turn started; survives continuation streams.
turn_started_at: Option<Instant>,
@@ -169,15 +172,15 @@ impl AiApp {
usage: Option<UsageSnapshot>,
initial_prompt: Option<String>,
usage_stale: bool,
settings: Settings,
settings: impl Into<Arc<Settings>>,
) -> Self {
Self {
in_git_project: io.app_ctx.git_root.is_some(),
project_scope: ProjectScope::detect(io.app_ctx.git_root.as_deref()),
io: Some(io),
usage,
initial_prompt,
usage_stale,
settings,
settings: settings.into(),
tips: TipRotation::new(),
..Self::headless(fsm, resume_notice, slash_registry, skill_names)
}
@@ -202,7 +205,7 @@ impl AiApp {
permission_select: SelectState::default(),
permission_prompt_for: None,
model_select: SelectState::default(),
in_git_project: false,
project_scope: ProjectScope::Directory,
initial_prompt: None,
usage_stale: false,
usage: None,
@@ -216,7 +219,7 @@ impl AiApp {
pushed_events: 0,
pushed_turns: 0,
exiting: false,
settings: Settings::utc(),
settings: Settings::utc().into(),
tips: TipRotation::starting_at(0),
turn_started_at: None,
turn_tip: None,
@@ -706,8 +709,9 @@ impl AiApp {
return;
};
let db = io.app_ctx.history_db.clone();
let settings = self.settings.clone();
ctx.perform(async move {
let outcome = history_call.execute(&db).await;
let outcome = history_call.execute(&db, &settings).await;
Msg::Fsm(Event::ToolExecutionDone {
tool_id,
outcome,
@@ -780,6 +784,7 @@ impl AiApp {
skill_summaries,
skill_overflow,
io.user_context_cache.clone(),
self.settings.clone(),
)));
}
@@ -1042,7 +1047,7 @@ impl App for AiApp {
.ctx
.tools
.awaiting_permission()
.map(|t| view::permission_options(&t.tool, self.in_git_project).len())
.map(|t| view::permission_options(&t.tool, self.project_scope).len())
.unwrap_or(0);
self.permission_select.handle(sel, len);
}
@@ -1051,7 +1056,7 @@ impl App for AiApp {
return;
};
let tool_id = tool.id.clone();
let options = view::permission_options(&tool.tool, self.in_git_project);
let options = view::permission_options(&tool.tool, self.project_scope);
let Some((_, result)) = options.get(self.permission_select.cursor) else {
return;
};
@@ -1179,7 +1184,7 @@ impl App for AiApp {
.when_some(asking, |c, tool| {
c.child(view::permission_prompt_view(
tool,
self.in_git_project,
self.project_scope,
self.permission_select.cursor,
))
})
@@ -1692,7 +1697,7 @@ mod tests {
#[rstest]
fn tips_disabled_in_settings_suppresses_the_tip_line() {
let mut app = app_with(AgentFsm::new(vec![], "t".into()));
app.settings.ai.tips = Some(false);
Arc::make_mut(&mut app.settings).ai.tips = Some(false);
let mut h = Harness::new(app);
h.type_str("hello");
h.press(KeyCode::Enter);
+5
View File
@@ -8,6 +8,9 @@
//! both aborts the HTTP stream and guarantees a replaced bridge can't
//! populate the user-context cache with a stale gather.
use std::sync::Arc;
use atuin_client::settings::Settings;
use futures::StreamExt;
use crate::context::{AppContext, ClientContext};
@@ -24,6 +27,7 @@ pub fn stream_bridge(
skill_summaries: Vec<SkillSummary>,
skill_overflow: Option<String>,
user_context_cache: UserContextCache,
settings: Arc<Settings>,
) -> impl futures::Stream<Item = Msg> + Send {
async_stream::stream! {
// User context files (TERMINAL.md) are gathered and interpolated on
@@ -46,6 +50,7 @@ pub fn stream_bridge(
user_contexts,
skill_summaries,
skill_overflow,
settings,
);
futures::pin_mut!(stream);
+70 -19
View File
@@ -14,6 +14,7 @@ use eye_declare::{
use ratatui_core::style::{Color, Modifier, Style};
use crate::fsm::tools::{InterruptReason, TrackedTool};
use crate::permissions::writer::ProjectScope;
use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview};
use crate::tui::events::PermissionResult;
use crate::tui::select;
@@ -660,10 +661,12 @@ fn suggested_command_view(details: &SuggestedCommandDetails) -> AnyElement<'stat
///
/// Edit/Write tools get a per-file session-scoped option instead of the
/// workspace-level "Always allow in this directory"; other tools keep the
/// standard set, with the directory label reflecting git-project status.
/// standard set, with the directory label reflecting the project scope.
/// The directory option isn't offered with [`ProjectScope::Home`] as it would
/// conflict with the global permissions file.
pub fn permission_options(
tool: &ClientToolCall,
in_git_project: bool,
project_scope: ProjectScope,
) -> Vec<(&'static str, PermissionResult)> {
match tool {
ClientToolCall::Edit(_) | ClientToolCall::Write(_) => vec![
@@ -672,26 +675,27 @@ pub fn permission_options(
("Always allow", PermissionResult::AlwaysAllow),
("Deny", PermissionResult::Deny),
],
_ => vec![
("Allow", PermissionResult::Allow),
(
if in_git_project {
"Always allow in this workspace"
} else {
"Always allow in this directory"
},
PermissionResult::AlwaysAllowInDir,
),
("Always allow", PermissionResult::AlwaysAllow),
("Deny", PermissionResult::Deny),
],
_ => [
Some(("Allow", PermissionResult::Allow)),
match project_scope {
ProjectScope::Workspace => Some("Always allow in this workspace"),
ProjectScope::Directory => Some("Always allow in this directory"),
ProjectScope::Home => None,
}
.map(|label| (label, PermissionResult::AlwaysAllowInDir)),
Some(("Always allow", PermissionResult::AlwaysAllow)),
Some(("Deny", PermissionResult::Deny)),
]
.into_iter()
.flatten()
.collect(),
}
}
/// `Atuin AI would like to <verb>: <target>` plus the options list.
pub fn permission_prompt_view(
tool_call: &TrackedTool,
in_git_project: bool,
project_scope: ProjectScope,
cursor: usize,
) -> AnyElement<'static> {
let verb = tool_call.tool.descriptor().display_verb;
@@ -715,7 +719,7 @@ pub fn permission_prompt_view(
}
ClientToolCall::LoadSkill(tool) => wrapped(format!("skill: {}", tool.name)),
};
let options = permission_options(&tool_call.tool, in_git_project);
let options = permission_options(&tool_call.tool, project_scope);
col()
.child(header)
@@ -835,9 +839,14 @@ fn format_path_for_display(path: &std::path::Path) -> String {
mod tests {
use std::time::Duration;
use super::format_elapsed;
use rstest::rstest;
#[test]
use super::{format_elapsed, permission_options};
use crate::permissions::writer::ProjectScope;
use crate::tools::ClientToolCall;
use crate::tui::events::PermissionResult;
#[rstest]
fn elapsed_formats_by_magnitude() {
assert_eq!(format_elapsed(Duration::from_millis(600)), "0.6 seconds");
assert_eq!(format_elapsed(Duration::from_millis(3240)), "3.2 seconds");
@@ -845,4 +854,46 @@ mod tests {
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 05s");
assert_eq!(format_elapsed(Duration::from_secs(154)), "2m 34s");
}
fn tool(name: &str, input: &serde_json::Value) -> ClientToolCall {
ClientToolCall::try_from((name, input)).unwrap()
}
/// "Always allow" for the project is offered, labelled for its scope, unless the project root
/// is the home directory: then a project rule would be global.
#[rstest]
#[case::workspace(ProjectScope::Workspace, Some("Always allow in this workspace"))]
#[case::directory(ProjectScope::Directory, Some("Always allow in this directory"))]
#[case::home(ProjectScope::Home, None)]
fn always_allow_in_project_is_not_offered_for_the_home_directory(
#[case] scope: ProjectScope,
#[case] expected: Option<&str>,
) {
let shell = tool("execute_shell_command", &serde_json::json!({ "command": "ls" }));
let options = permission_options(&shell, scope);
let in_project: Vec<_> = options
.iter()
.filter(|(_, result)| matches!(result, PermissionResult::AlwaysAllowInDir))
.map(|(label, _)| *label)
.collect();
assert_eq!(in_project, expected.into_iter().collect::<Vec<_>>());
// The other options are always there.
let labels: Vec<_> = options.iter().map(|(label, _)| *label).collect();
for label in ["Allow", "Always allow", "Deny"] {
assert!(labels.contains(&label), "{labels:?}");
}
}
/// Edits and writes never offer it, whatever the scope.
#[rstest]
fn edits_are_offered_the_same_options_in_every_scope(
#[values(ProjectScope::Workspace, ProjectScope::Directory, ProjectScope::Home)]
scope: ProjectScope,
) {
let write =
tool("write_file", &serde_json::json!({ "file_path": "/tmp/file", "content": "text" }));
let labels: Vec<_> =
permission_options(&write, scope).into_iter().map(|(label, _)| label).collect();
assert_eq!(labels, ["Allow", "Allow this file for this session", "Always allow", "Deny"]);
}
}
+45 -7
View File
@@ -8,6 +8,7 @@
use std::path::{Path, PathBuf};
use atuin_common::path::PathExt as _;
use eyre::Result;
use tokio::task::JoinSet;
@@ -38,11 +39,13 @@ pub async fn walk(start: &Path, global_path: Option<&Path>) -> Result<Vec<RawCon
let mut set: JoinSet<Result<Option<FoundFile>>> = JoinSet::new();
for (index, dir) in dirs.into_iter().enumerate() {
let dir2 = dir.clone();
set.spawn(async move {
load_context_file(&dir.join(".atuin").join(CONTEXT_FILENAME), index).await
});
set.spawn(async move { load_context_file(&dir2.join(CONTEXT_FILENAME), index).await });
let dotdir_file =
PathBuf::from_iter([dir.as_path(), ".atuin".as_ref(), CONTEXT_FILENAME.as_ref()]);
// Avoid loading ~/.atuin/TERMINAL.md twice.
if !global_path.is_some_and(|global| dotdir_file.is_same_path(global)) {
set.spawn(async move { load_context_file(&dotdir_file, index).await });
}
set.spawn(async move { load_context_file(&dir.join(CONTEXT_FILENAME), index).await });
}
if let Some(global) = global_path {
@@ -70,9 +73,9 @@ pub async fn walk(start: &Path, global_path: Option<&Path>) -> Result<Vec<RawCon
Ok(found.into_iter().map(|f| f.file).collect())
}
/// The default global context file path (`~/.config/atuin/TERMINAL.md`).
/// The default global context file path (`~/.atuin/TERMINAL.md`).
pub fn global_context_path() -> PathBuf {
atuin_common::utils::config_dir().join(CONTEXT_FILENAME)
atuin_common::dirs::config_path(CONTEXT_FILENAME)
}
async fn load_context_file(path: &Path, depth: usize) -> Result<Option<FoundFile>> {
@@ -88,3 +91,38 @@ async fn load_context_file(path: &Path, depth: usize) -> Result<Option<FoundFile
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
/// A `.atuin/TERMINAL.md` above the working directory that is also the global file, as
/// `~/.atuin/TERMINAL.md` is by default, is loaded once. Any other is a project's, and is still
/// loaded alongside the global one.
#[rstest]
#[case::global(true, 1)]
#[case::project(false, 2)]
#[tokio::test]
async fn the_global_file_is_not_also_a_project_file(
#[case] is_global: bool,
#[case] expected: usize,
) {
let home = tempfile::tempdir().unwrap();
let dotfile = home.path().join(".atuin").join(CONTEXT_FILENAME);
let elsewhere = home.path().join("elsewhere").join(CONTEXT_FILENAME);
for file in [&dotfile, &elsewhere] {
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, "context").unwrap();
}
let global = if is_global {
dotfile
} else {
elsewhere
};
let files = walk(home.path(), Some(&global)).await.unwrap();
assert_eq!(files.len(), expected);
}
}
+5 -22
View File
@@ -1,19 +1,3 @@
## Base directory for Atuin data files (databases, keys, session, etc.)
## All data file paths default to being relative to this directory.
## linux/mac: ~/.local/share/atuin (or XDG_DATA_HOME/atuin)
## windows: %USERPROFILE%/.local/share/atuin
# data_dir = "~/.local/share/atuin"
## where to store your database, default is your system data directory
## linux/mac: ~/.local/share/atuin/history.db
## windows: %USERPROFILE%/.local/share/atuin/history.db
# db_path = "~/.history.db"
## where to store your encryption key, default is your system data directory
## linux/mac: ~/.local/share/atuin/key
## windows: %USERPROFILE%/.local/share/atuin/key
# key_path = "~/.key"
## date format used, either "us" or "uk"
# dialect = "us"
@@ -285,8 +269,7 @@ enter_accept = true
# socket_path = "/custom/path/to/atuin.sock"
## The daemon pidfile used for lifecycle management.
## Defaults to the Atuin data directory.
# pidfile_path = "~/.local/share/atuin/atuin-daemon.pid"
# pidfile_path = "~/.atuin/data/atuin-daemon.pid"
## Use systemd socket activation rather than opening the given path (the path must still be correct).
## Note: setting this to true changes the default value of socket_path; see above.
@@ -310,10 +293,10 @@ enter_accept = true
# [theme]
## Color theme to use for rendering in the terminal.
## There are some built-in themes, including the base theme ("default"),
## "autumn" and "marine". You can add your own themes to the "./themes" subdirectory of your
## Atuin config (or ATUIN_THEME_DIR, if provided) as TOML files whose keys should be one or
## more of AlertInfo, AlertWarn, AlertError, Annotation, Base, Guidance, Important, and
## the string values as lowercase entries from this list:
## "autumn" and "marine". You can add your own themes to `~/.atuin/themes/` as
## TOML files whose keys should be one or more of AlertInfo, AlertWarn,
## AlertError, Annotation, Base, Guidance, Important, and the string values as
## lowercase entries from this list:
## https://ogeon.github.io/docs/palette/master/palette/named/index.html
## If you provide a custom theme file, it should be called "NAME.toml" and the theme below
## should be the stem, i.e. `theme = "NAME"` for your chosen NAME.
+4 -4
View File
@@ -11,12 +11,12 @@ pub use database::*;
/// Where the daemon keeps the ai-session sidecar (see [`Settings::ai_session_sidecar_path`]).
#[must_use]
pub fn sidecar_path() -> PathBuf {
Settings::ai_session_sidecar_path()
pub fn sidecar_path(settings: &Settings) -> PathBuf {
settings.ai_session_sidecar_path()
}
/// Have the daemon reproject the sidecar from the whole record store on its next start, for
/// commands that re-encrypt the records under it (which changes none of what they say).
pub async fn invalidate_sidecar() -> Result<(), DbError> {
AiSessionDatabase::invalidate_projection(sidecar_path()).await
pub async fn invalidate_sidecar(settings: &Settings) -> Result<(), DbError> {
AiSessionDatabase::invalidate_projection(sidecar_path(settings)).await
}
+2 -2
View File
@@ -712,6 +712,7 @@ enum Pass {
mod tests {
use std::ops::Range;
use atuin_common::env;
use atuin_common::harnesstools::session::{Content, Role};
use atuin_domain::record::HostId;
use futures::TryStreamExt;
@@ -1699,8 +1700,7 @@ mod tests {
#[tokio::test]
#[ignore = "benchmark"]
async fn reproject_timing() {
let records: i64 =
std::env::var("REPROJECT_RECORDS").map_or(20_000, |n| n.parse().unwrap());
let records: i64 = env::var("REPROJECT_RECORDS").map_or(20_000, |n| n.parse().unwrap());
let dir = tempfile::tempdir().unwrap();
let store =
SqliteStore::new(dir.path().join("records.db"), test_local_timeout()).await.unwrap();
-1
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::env;
use std::ops::Range;
use std::sync::Arc;
use std::time::Duration;
+6 -6
View File
@@ -69,11 +69,11 @@ pub struct OptFilters<'a> {
/// `ATUIN_SESSION` is unset; the session is left empty so session-scoped
/// filters simply match nothing.
#[instrument(level = "trace", skip_all, err)]
pub async fn query_context() -> eyre::Result<Context> {
let session = env::var("ATUIN_SESSION").unwrap_or_default();
pub async fn query_context(settings: &Settings) -> eyre::Result<Context> {
let session = atuin_common::env::var("ATUIN_SESSION").unwrap_or_default();
let cmd_origin = CmdOrigin::probe_current();
let cwd = utils::get_current_dir();
let host_id = Settings::host_id().await?;
let host_id = settings.host_id().await?;
let git_root = utils::in_git_repo(cwd.as_str());
Ok(Context {
@@ -86,15 +86,15 @@ pub async fn query_context() -> eyre::Result<Context> {
}
#[instrument(level = "trace", skip_all, err)]
pub async fn current_context() -> eyre::Result<Context> {
if env::var("ATUIN_SESSION").is_err() {
pub async fn current_context(settings: &Settings) -> eyre::Result<Context> {
if atuin_common::env::var("ATUIN_SESSION").is_err() {
return Err(eyre::eyre!(
"Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set \
up your shell."
));
}
query_context().await
query_context(settings).await
}
impl Context {
+5 -6
View File
@@ -1,4 +1,3 @@
use std::env;
use std::sync::LazyLock;
use atuin_common::filter::OrFilter;
@@ -68,7 +67,7 @@ impl AuthorKind {
/// The kind stated by the invoking integration's environment (`ATUIN_HISTORY_AUTHOR_KIND`).
#[must_use]
pub fn probe_current() -> Option<Self> {
let value = env::var(HISTORY_AUTHOR_KIND_ENV).ok()?;
let value = atuin_common::env::var(HISTORY_AUTHOR_KIND_ENV).ok()?;
clap::ValueEnum::from_str(&value, true).ok()
}
}
@@ -124,7 +123,7 @@ const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";
/// The author identity exported by the invoking integration (`ATUIN_HISTORY_AUTHOR`).
#[must_use]
pub fn probe_author() -> Option<String> {
normalize_optional_string(env::var(HISTORY_AUTHOR_ENV).ok())
normalize_optional_string(atuin_common::env::var(HISTORY_AUTHOR_ENV).ok())
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, derive_more::Display)]
@@ -398,14 +397,14 @@ impl History {
author_kind: Option<AuthorKind>,
) -> Self {
let session = session
.or_else(|| env::var("ATUIN_SESSION").ok())
.or_else(|| atuin_common::env::var("ATUIN_SESSION").ok())
.unwrap_or_else(|| uuid_v7().as_simple().to_string());
let cmd_origin = cmd_origin.unwrap_or_else(CmdOrigin::probe_current);
let author = normalize_optional_string(author)
.or_else(|| normalize_optional_string(env::var(HISTORY_AUTHOR_ENV).ok()))
.or_else(|| normalize_optional_string(atuin_common::env::var(HISTORY_AUTHOR_ENV).ok()))
.unwrap_or_else(|| cmd_origin.user().to_string());
let intent = normalize_optional_string(intent)
.or_else(|| normalize_optional_string(env::var(HISTORY_INTENT_ENV).ok()));
.or_else(|| normalize_optional_string(atuin_common::env::var(HISTORY_INTENT_ENV).ok()));
let shell = normalize_optional_string(shell);
Self {
+3 -2
View File
@@ -20,6 +20,7 @@ use tracing_futures::Instrument;
use super::{History, HistoryId, Version};
use crate::database::{Sqlite, current_context};
use crate::record::sqlite_store::SqliteStore;
use crate::settings::Settings;
#[derive(Debug, Clone)]
pub struct HistoryStore {
@@ -493,7 +494,7 @@ impl HistoryStore {
}
#[instrument(level = "trace", skip_all, fields(host = ?self.host_id), err)]
pub async fn init_store(&self, db: &Sqlite) -> Result<()> {
pub async fn init_store(&self, db: &Sqlite, settings: &Settings) -> Result<()> {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.blue} {msg}")
@@ -507,7 +508,7 @@ impl HistoryStore {
pb.set_message("Fetching history from old database");
let context = current_context().await?;
let context = current_context(settings).await?;
let history = db.list([], &context, None, false, true, None).await?;
pb.set_message("Fetching history already in store");
+15 -15
View File
@@ -159,7 +159,7 @@ impl HubAuthSession {
/// Linux) or the launcher failed to start, so callers should always print the URL as well.
#[must_use]
pub fn open_in_browser(&self) -> bool {
let Some(program) = browser_launcher(|var| std::env::var_os(var).is_some()) else {
let Some(program) = browser_launcher(|var| atuin_common::env::var_os(var).is_some()) else {
return false;
};
@@ -250,33 +250,29 @@ fn browser_launcher(is_set: impl Fn(&str) -> bool) -> Option<&'static str> {
///
/// This saves the token to the meta store so it can be used for subsequent Hub API calls.
/// Note: This is separate from the sync session token.
pub async fn save_session(token: &SecretString) -> Result<()> {
Settings::meta_store()
.await?
.save_hub_session(token)
.await
.context("Failed to save hub session")
pub async fn save_session(token: &SecretString, settings: &Settings) -> Result<()> {
settings.meta_store().await?.save_hub_session(token).await.context("Failed to save hub session")
}
/// Delete the hub session token (logout from Hub)
pub async fn delete_session() -> Result<()> {
Settings::meta_store().await?.delete_hub_session().await.context("Failed to delete hub session")
pub async fn delete_session(settings: &Settings) -> Result<()> {
settings.meta_store().await?.delete_hub_session().await.context("Failed to delete hub session")
}
/// Check if the user is logged in with Hub authentication
///
/// Returns true if the user has a valid Hub session token.
/// This is independent of whether they have a sync session.
pub async fn is_logged_in() -> Result<bool> {
Settings::meta_store().await?.hub_logged_in().await
pub async fn is_logged_in(settings: &Settings) -> Result<bool> {
settings.meta_store().await?.hub_logged_in().await
}
/// Get the hub session token if available
///
/// Returns the Hub session token if the user is logged in with Hub auth,
/// or None if not logged in.
pub async fn get_session_token() -> Result<Option<SecretString>> {
Settings::meta_store().await?.hub_session_token().await
pub async fn get_session_token(settings: &Settings) -> Result<Option<SecretString>> {
settings.meta_store().await?.hub_session_token().await
}
/// Link an existing CLI sync account to the current Hub user.
@@ -294,8 +290,12 @@ pub async fn get_session_token() -> Result<Option<SecretString>> {
/// - Not logged in to Hub
/// - CLI token is invalid
/// - CLI account is already linked to a different Hub account
pub async fn link_account(hub_address: &Url, cli_token: &SecretString) -> Result<()> {
let hub_token = get_session_token()
pub async fn link_account(
hub_address: &Url,
cli_token: &SecretString,
settings: &Settings,
) -> Result<()> {
let hub_token = get_session_token(settings)
.await?
.ok_or_else(|| eyre::eyre!("Not logged in to Hub - cannot link account"))?;
+3 -2
View File
@@ -4,6 +4,7 @@
use std::path::PathBuf;
use async_trait::async_trait;
use atuin_common::env;
use eyre::{Result, eyre};
use time::OffsetDateTime;
@@ -18,7 +19,7 @@ pub struct Fish {
/// see <https://fishshell.com/docs/current/interactive.html#searchable-command-history>
fn default_histpath() -> Result<PathBuf> {
let data = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).ok_or(()).or_else(|_| {
let data = env::var_os("XDG_DATA_HOME").map(PathBuf::from).ok_or(()).or_else(|_| {
let Some(home) = dirs::home_dir() else {
eyre::bail!("could not determine home directory");
};
@@ -27,7 +28,7 @@ fn default_histpath() -> Result<PathBuf> {
// fish supports multiple history sessions
// If `fish_history` var is missing, or set to `default`, use `fish` as the session
let session = std::env::var("fish_history").unwrap_or_else(|_| String::from("fish"));
let session = env::var("fish_history").unwrap_or_else(|_| String::from("fish"));
let session = if session == "default" {
String::from("fish")
} else {
+1 -1
View File
@@ -73,7 +73,7 @@ fn get_histpath<D>(def: D) -> Result<PathBuf>
where
D: FnOnce() -> Result<PathBuf>,
{
if let Ok(p) = std::env::var("HISTFILE") {
if let Ok(p) = atuin_common::env::var("HISTFILE") {
Ok(PathBuf::from(p))
} else {
def()
+1 -1
View File
@@ -33,7 +33,7 @@ fn get_history_path() -> Result<PathBuf> {
.join("PowerShell")
.join("PSReadLine")
} else {
std::env::var_os("XDG_DATA_HOME")
atuin_common::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.ok_or(())
.or_else(|_| {
+3 -3
View File
@@ -1,4 +1,3 @@
use std::env;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
@@ -109,7 +108,7 @@ impl Importer for Xonsh {
async fn new() -> Result<Self> {
// wrap xonsh-specific path resolver in general one so that it respects $HISTPATH
let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok();
let xonsh_data_dir = atuin_common::env::var("XONSH_DATA_DIR").ok();
let hist_dir = get_histdir_path(|| xonsh_hist_dir(xonsh_data_dir))?;
let sessions = load_sessions(&hist_dir)?;
let cmd_origin = CmdOrigin::probe_current();
@@ -156,13 +155,14 @@ impl Importer for Xonsh {
#[cfg(test)]
mod tests {
use rstest::rstest;
use time::macros::datetime;
use super::*;
use crate::history::History;
use crate::import::tests::TestLoader;
#[test]
#[rstest]
fn test_hist_dir_xonsh() {
let hist_dir = xonsh_hist_dir(Some("/home/user/xonsh_data".to_string())).unwrap();
assert_eq!(hist_dir, PathBuf::from("/home/user/xonsh_data/history_json"));
@@ -1,4 +1,3 @@
use std::env;
use std::path::PathBuf;
use async_trait::async_trait;
@@ -96,7 +95,7 @@ impl Importer for XonshSqlite {
async fn new() -> Result<Self> {
// wrap xonsh-specific path resolver in general one so that it respects $HISTPATH
let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok();
let xonsh_data_dir = atuin_common::env::var("XONSH_DATA_DIR").ok();
let db_path = get_histfile_path(|| xonsh_db_path(xonsh_data_dir))?;
let connection_str = db_path.to_str().ok_or_else(|| {
eyre!("Invalid path for SQLite database: {}", db_path.to_string_lossy())
@@ -138,19 +137,20 @@ impl Importer for XonshSqlite {
#[cfg(test)]
mod tests {
use rstest::rstest;
use time::macros::datetime;
use super::*;
use crate::history::History;
use crate::import::tests::TestLoader;
#[test]
#[rstest]
fn test_db_path_xonsh() {
let db_path = xonsh_db_path(Some("/home/user/xonsh_data".to_string())).unwrap();
assert_eq!(db_path, PathBuf::from("/home/user/xonsh_data/xonsh-history.sqlite"));
}
#[test]
#[rstest]
fn out_of_range_timestamp_falls_back_to_epoch() {
let entry = HistDbEntry {
inp: "echo hello".to_string(),
+10 -8
View File
@@ -102,7 +102,7 @@ impl ZshHistDb {
//
// if [[ -z ${HISTDB_FILE} ]]; then
// typeset -g HISTDB_FILE="${HOME}/.histdb/zsh-history.db"
if let Ok(path) = std::env::var("HISTDB_FILE") {
if let Ok(path) = atuin_common::env::var("HISTDB_FILE") {
return Ok(PathBuf::from(path));
}
@@ -175,28 +175,29 @@ impl Importer for ZshHistDb {
#[cfg(test)]
mod test {
use std::env;
use atuin_common::env::MockEnv;
use rstest::rstest;
use sqlx::sqlite::SqlitePoolOptions;
use super::*;
#[rstest]
#[tokio::test(flavor = "multi_thread")]
#[allow(unsafe_code)]
async fn test_env_vars() {
let test_env_db = "nonstd-zsh-history.db";
let key = "HISTDB_FILE";
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var(key, test_env_db) };
let env = MockEnv::install();
env.set(key, test_env_db);
// test the env got set
assert_eq!(env::var(key).unwrap(), test_env_db.to_string());
assert_eq!(atuin_common::env::var(key).unwrap(), test_env_db.to_string());
// test histdb returns the proper db from previous step
let histdb_path = ZshHistDb::histpath_candidate().unwrap();
assert_eq!(histdb_path.to_str().unwrap(), test_env_db);
}
#[rstest]
#[tokio::test]
async fn duration_saturates_instead_of_overflowing() {
use time::macros::datetime;
@@ -226,6 +227,7 @@ mod test {
assert_eq!(loader.buf[0].duration, i64::MAX);
}
#[rstest]
#[tokio::test(flavor = "multi_thread")]
async fn test_import() {
let pool: SqlitePool =
+2 -2
View File
@@ -2,8 +2,8 @@ use eyre::Result;
use crate::settings::Settings;
pub async fn logout() -> Result<()> {
let meta = Settings::meta_store().await?;
pub async fn logout(settings: &Settings) -> Result<()> {
let meta = settings.meta_store().await?;
if meta.logged_in().await? {
meta.delete_session().await?;
+38 -8
View File
@@ -1,4 +1,5 @@
use std::ffi::OsStr;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
@@ -45,7 +46,15 @@ pub struct MetaStore {
}
impl MetaStore {
pub async fn new(path: impl AsRef<OsStr>, timeout: Duration) -> Result<Self> {
/// Load the meta store at `path`.
///
/// Legacy plain-text files in `data_dir` will be migrated to the meta store when the meta
/// store is first created.
pub async fn new(
path: impl AsRef<OsStr>,
data_dir: impl AsRef<Path>,
timeout: Duration,
) -> Result<Self> {
let path = path.as_ref();
debug!("opening meta sqlite database at {path:?}");
@@ -55,7 +64,7 @@ impl MetaStore {
let store = Self::from_builder(builder, timeout).await?;
if !ephemeral {
store.migrate_files().await?;
store.migrate_files(data_dir.as_ref()).await?;
}
Ok(store)
@@ -270,13 +279,11 @@ impl MetaStore {
// File migration: on first open, migrate old plain-text files into the database.
// Old files are left in place for safe downgrades.
async fn migrate_files(&self) -> Result<()> {
async fn migrate_files(&self, data_dir: &Path) -> Result<()> {
if self.get(KEY_FILES_MIGRATED).await?.is_some() {
return Ok(());
}
let data_dir = crate::settings::Settings::effective_data_dir();
// host_id — validate as UUID
let host_id_path = data_dir.join(LEGACY_HOST_ID_FILENAME);
if host_id_path.exists()
@@ -445,10 +452,28 @@ mod tests {
assert_eq!(store.latest_version().await.unwrap(), Some("1.2.3".to_string()));
}
#[rstest]
#[tokio::test]
async fn legacy_files_are_migrated_from_the_given_data_dir() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let host_id = Uuid::now_v7().as_simple().to_string();
std::fs::write(data_dir.join(LEGACY_HOST_ID_FILENAME), &host_id).unwrap();
let store = MetaStore::new(tmp.path().join("meta.db"), &data_dir, Duration::from_secs(2))
.await
.unwrap();
assert_eq!(store.get(KEY_HOST_ID).await.unwrap(), Some(host_id));
assert!(store.get(KEY_FILES_MIGRATED).await.unwrap().is_some());
}
#[rstest]
#[tokio::test]
async fn memory_store_skips_file_migration() {
let store = MetaStore::new(":memory:", Duration::from_secs(2)).await.unwrap();
let store =
MetaStore::new(":memory:", "/nonexistent", Duration::from_secs(2)).await.unwrap();
assert_eq!(store.get(KEY_FILES_MIGRATED).await.unwrap(), None);
}
@@ -463,7 +488,12 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("meta.db");
MetaStore::new(&path, Duration::from_secs(2)).await.unwrap().set("k", "v").await.unwrap();
MetaStore::new(&path, dir.path(), Duration::from_secs(2))
.await
.unwrap()
.set("k", "v")
.await
.unwrap();
let mut writer =
sqlx::sqlite::SqliteConnectOptions::new().filename(&path).connect().await.unwrap();
@@ -478,7 +508,7 @@ mod tests {
writer.close().await.unwrap();
});
let store = MetaStore::new(&path, Duration::from_secs(2)).await.unwrap();
let store = MetaStore::new(&path, dir.path(), Duration::from_secs(2)).await.unwrap();
assert_eq!(store.get("k").await.unwrap().as_deref(), Some("w"));
commit.await.unwrap();
}
+83 -108
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, OnceLock};
use std::sync::LazyLock;
use std::time::Duration;
use atuin_common::logs::LogLevel;
@@ -30,15 +30,11 @@ use url::Url;
static EXAMPLE_CONFIG: &str = include_str!("../config.toml");
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
static META_STORE: OnceCell<crate::meta::MetaStore> = OnceCell::const_new();
pub mod ai_sessions;
pub mod daemon;
pub mod disk_usage_limit;
mod kv;
pub(crate) mod meta;
pub mod meta;
pub mod output;
mod scripts;
pub mod security;
@@ -1056,7 +1052,7 @@ impl Default for Ui {
#[serde_as]
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Settings {
pub data_dir: Option<String>,
pub data_dir: PathBuf,
pub dialect: Dialect,
pub timezone: UtcOffsetSpec,
pub style: Style,
@@ -1229,95 +1225,89 @@ impl Settings {
.unwrap_or_else(|| self.search_mode())
}
/// The resolved data directory: a custom `data_dir` / `ATUIN_DATA_DIR` if one was configured
/// when settings were last built this process, otherwise [`atuin_common::utils::data_dir`].
#[must_use]
pub fn effective_data_dir() -> PathBuf {
DATA_DIR.get().cloned().unwrap_or_else(atuin_common::utils::data_dir)
}
/// Directory of the durable command-output capture store, under the [effective data
/// dir](Self::effective_data_dir). Unlike the sqlite stores this is a fjall database
/// directory, not a single file.
/// Directory of the durable command-output capture store, under the [data dir](Self::data_dir).
/// Unlike the sqlite stores this is a fjall database directory, not a single file.
///
/// The user should not mess with this directory. Bad things can happen.
#[must_use]
pub fn command_capture_dir() -> PathBuf {
Self::effective_data_dir().join("output-capture")
pub fn command_capture_dir(&self) -> PathBuf {
self.data_dir.join("output-capture")
}
/// The AI harness session sidecar database, under the [effective data
/// dir](Self::effective_data_dir). The daemon owns and writes it; other readers open it
/// read-only (`AiSessionDatabase::open_read_only`).
/// The AI harness session sidecar database, under the [data dir](Self::data_dir). The daemon
/// owns and writes it; other readers open it read-only (`AiSessionDatabase::open_read_only`).
#[must_use]
pub fn ai_session_sidecar_path() -> PathBuf {
Self::effective_data_dir().join("ai_session_sidecar.db")
pub fn ai_session_sidecar_path(&self) -> PathBuf {
self.data_dir.join("ai_session_sidecar.db")
}
#[must_use]
pub fn octavo_queue_path() -> PathBuf {
Self::effective_data_dir().join("octavo.db")
pub fn octavo_queue_path(&self) -> PathBuf {
self.data_dir.join("octavo.db")
}
// -- Meta store: lazily initialized on first access --
pub async fn meta_store() -> Result<&'static crate::meta::MetaStore> {
/// Load the meta store.
///
/// Note: there is one meta store per process. The first time this method is called, the meta
/// store will be initialized from the parameters in that [`Settings`] instance (most notably
/// [`meta.db_path`](meta::Settings::db_path)). All future calls to this method will return the
/// same [`MetaStore`](crate::meta::MetaStore), even if called on a different [`Settings`]
/// instance. This matches existing behavior.
pub async fn meta_store(&self) -> Result<&'static crate::meta::MetaStore> {
static META_STORE: OnceCell<crate::meta::MetaStore> = OnceCell::const_new();
META_STORE
.get_or_try_init(|| async {
let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
eyre!("meta store config not set — Settings::new() has not been called")
})?;
crate::meta::MetaStore::new(
db_path,
std::time::Duration::try_from_secs_f64(*timeout)?,
)
.await
crate::meta::MetaStore::new(&self.meta.db_path, &self.data_dir, self.local_timeout)
.await
})
.await
}
pub async fn host_id() -> Result<HostId> {
Self::meta_store().await?.host_id().await
pub async fn host_id(&self) -> Result<HostId> {
self.meta_store().await?.host_id().await
}
pub async fn last_sync() -> Result<OffsetDateTime> {
Self::meta_store().await?.last_sync().await
pub async fn last_sync(&self) -> Result<OffsetDateTime> {
self.meta_store().await?.last_sync().await
}
pub async fn save_sync_time() -> Result<()> {
Self::meta_store().await?.save_sync_time().await
pub async fn save_sync_time(&self) -> Result<()> {
self.meta_store().await?.save_sync_time().await
}
pub async fn last_version_check() -> Result<OffsetDateTime> {
Self::meta_store().await?.last_version_check().await
pub async fn last_version_check(&self) -> Result<OffsetDateTime> {
self.meta_store().await?.last_version_check().await
}
pub async fn save_version_check_time() -> Result<()> {
Self::meta_store().await?.save_version_check_time().await
pub async fn save_version_check_time(&self) -> Result<()> {
self.meta_store().await?.save_version_check_time().await
}
pub async fn should_sync(&self) -> Result<bool> {
if !self.auto_sync || !Self::meta_store().await?.logged_in().await? {
if !self.auto_sync || !self.meta_store().await?.logged_in().await? {
return Ok(false);
}
Ok(OffsetDateTime::now_utc() - Self::last_sync().await?
Ok(OffsetDateTime::now_utc() - self.last_sync().await?
>= time::Duration::try_from(self.sync_frequency)?)
}
pub async fn logged_in(&self) -> Result<bool> {
Self::meta_store().await?.logged_in().await
self.meta_store().await?.logged_in().await
}
pub async fn session_token(&self) -> Result<SecretString> {
match Self::meta_store().await?.session_token().await? {
match self.meta_store().await?.session_token().await? {
Some(token) => Ok(token),
None => Err(eyre!("Tried to load session; not logged in")),
}
}
pub async fn hub_session_token(&self) -> Result<SecretString> {
match Self::meta_store().await?.hub_session_token().await? {
match self.meta_store().await?.hub_session_token().await? {
Some(token) => Ok(token),
None => Err(eyre!("Tried to load hub session; not logged in")),
}
@@ -1379,7 +1369,7 @@ impl Settings {
#[cfg(feature = "sync")]
#[must_use]
pub async fn resolve_sync_auth(&self) -> SyncAuth {
let meta = match Self::meta_store().await {
let meta = match self.meta_store().await {
Ok(m) => m,
Err(e) => {
// Unlike not being logged in, a failure: callers tracing the result at debug
@@ -1450,7 +1440,7 @@ impl Settings {
#[cfg(feature = "check-update")]
async fn needs_update_check(&self) -> Result<bool> {
let last_check = Self::last_version_check().await?;
let last_check = self.last_version_check().await?;
let diff = OffsetDateTime::now_utc() - last_check;
// Check a max of once per hour
@@ -1465,7 +1455,7 @@ impl Settings {
Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100_000, 0, 0));
if !self.needs_update_check().await? {
let meta = Self::meta_store().await?;
let meta = self.meta_store().await?;
let version = match meta.latest_version().await? {
Some(v) => Version::parse(&v).unwrap_or(current),
None => current,
@@ -1480,8 +1470,8 @@ impl Settings {
#[cfg(not(feature = "sync"))]
let latest = current;
let meta = Self::meta_store().await?;
Self::save_version_check_time().await?;
let meta = self.meta_store().await?;
self.save_version_check_time().await?;
meta.save_latest_version(&latest.to_string()).await?;
Ok(latest)
@@ -1540,7 +1530,7 @@ impl Settings {
}
pub fn builder() -> Result<ConfigBuilder<DefaultState>> {
Ok(Self::builder_with_data_dir(&atuin_common::utils::data_dir())?)
Ok(Self::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())?)
}
fn builder_with_data_dir(
@@ -1552,7 +1542,7 @@ impl Settings {
let scripts_path = data_dir.join("scripts.db");
let ai_sessions_path = data_dir.join("ai_sessions.db");
let pidfile_path = data_dir.join("atuin-daemon.pid");
let logs_dir = atuin_common::utils::logs_dir();
let logs_dir = atuin_common::dirs::logs_dir();
let key_path = data_dir.join("key");
let meta_path = data_dir.join("meta.db");
@@ -1561,6 +1551,7 @@ impl Settings {
Ok(Config::builder()
.set_default("history_format", "{time}\t{command}\t{duration}")?
.set_default("data_dir", data_dir.to_str())?
.set_default("db_path", db_path.to_str())?
.set_default("record_store_path", record_store_path.to_str())?
.set_default("key_path", key_path.to_str())?
@@ -1664,10 +1655,7 @@ 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))),
atuin_common::env::var_os("NO_MOTION").is_some(),
)?
.set_default("no_mouse", false)?
.set_default("pty_proxy.enabled", false)?
@@ -1678,20 +1666,12 @@ impl Settings {
}
pub fn get_config_path() -> Result<PathBuf> {
let config_dir = atuin_common::utils::config_dir();
let config_file = atuin_common::dirs::config_path("config.toml");
create_dir_all(&config_dir)
.wrap_err_with(|| format!("could not create dir {config_dir:?}"))?;
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
};
config_file.push("config.toml");
if let Some(config_dir) = config_file.parent() {
create_dir_all(config_dir)
.wrap_err_with(|| format!("could not create dir {config_dir:?}"))?;
}
Ok(config_file)
}
@@ -1730,20 +1710,18 @@ impl Settings {
.map_err(|e| eyre!("failed to expand data_dir path: {}", e))?;
PathBuf::from(expanded.as_ref())
}
None => atuin_common::utils::data_dir(),
None => atuin_common::dirs::unconfigured_data_dir(),
}
} else {
atuin_common::utils::data_dir()
atuin_common::dirs::unconfigured_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:?}"))?;
let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
let config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
config_builder = if config_file.exists() {
let config_builder = if config_file.exists() {
let config_file_str =
config_file.to_str().ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
@@ -1755,10 +1733,15 @@ impl Settings {
config_builder
};
// Explicitly set `data_dir` here to ensure it matches the `effective_data_dir` we just
// calculated.
let config_builder =
config_builder.set_override("data_dir", effective_data_dir.to_str())?;
// all paths should be expanded
let built = config_builder.build_cloned()?;
config_builder = [
let config_builder = [
"db_path",
"record_store_path",
"key_path",
@@ -1767,6 +1750,7 @@ impl Settings {
"logs.dir",
"logs.search.file",
"logs.daemon.file",
"logs.ai.file",
]
.iter()
.map(|key| (key, built.get_string(key).unwrap_or_default()))
@@ -1881,9 +1865,6 @@ impl Settings {
// Validate UI settings
settings.ui.validate()?;
// Register meta store config for lazy initialization on first access
META_CONFIG.set((settings.meta.db_path.clone(), settings.local_timeout.as_secs_f64())).ok();
Ok(settings)
}
@@ -1911,12 +1892,12 @@ impl Settings {
/// Check that a TOML string can be successfully deserialized into a [`Settings`] object.
pub fn validate_str(toml: &str) -> Result<(), ValidationError> {
let config = Self::builder_with_data_dir(&atuin_common::utils::data_dir())?
let config = Self::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())?
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
.build()?;
let settings: Self = config.try_deserialize()?;
if let Some(dir) = &settings.data_dir {
if let Some(dir) = settings.data_dir.to_str() {
shellexpand::full(dir).map_err(ValidationError::DataDir)?;
}
@@ -1951,23 +1932,9 @@ pub enum ValidationError {
DataDir(shellexpand::LookupError<std::env::VarError>),
}
/// Initialize the meta store configuration for testing.
///
/// This should only be used in tests. It allows tests to bypass the normal
/// Settings::new() flow while still being able to use Settings::host_id()
/// and other meta store dependent functions.
///
/// # Safety
/// This function is not thread-safe with concurrent calls to Settings::new()
/// or other meta store initialization. Only call from tests.
#[doc(hidden)]
pub fn init_meta_config_for_testing(meta_db_path: impl Into<String>, local_timeout: f64) {
META_CONFIG.set((meta_db_path.into(), local_timeout)).ok();
}
#[cfg(test)]
pub(crate) fn test_local_timeout() -> Duration {
let secs = std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
let secs = atuin_common::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
.ok()
.and_then(|x| x.parse::<f64>().ok())
// this hardcoded value should be replaced by a simple way to get the
@@ -1978,6 +1945,7 @@ pub(crate) fn test_local_timeout() -> Duration {
#[cfg(test)]
mod tests {
use std::path::Path;
use std::str::FromStr;
use eyre::Result;
@@ -2081,6 +2049,7 @@ mod tests {
let builder = super::Settings::builder_with_data_dir(&custom_dir)?;
let config = builder.build()?;
let data_dir: String = config.get("data_dir")?;
let db_path: String = config.get("db_path")?;
let key_path: String = config.get("key_path")?;
let record_store_path: String = config.get("record_store_path")?;
@@ -2091,6 +2060,7 @@ mod tests {
let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?;
let daemon_autostart: bool = config.get("daemon.autostart")?;
assert_eq!(data_dir, custom_dir.to_str().unwrap());
assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
assert_eq!(record_store_path, custom_dir.join("records.db").to_str().unwrap());
@@ -2133,12 +2103,17 @@ mod tests {
}
#[rstest]
fn effective_data_dir_returns_default_when_not_set() {
let effective = super::Settings::effective_data_dir();
let default = atuin_common::utils::data_dir();
assert!(effective.to_str().is_some());
assert!(effective.ends_with("atuin") || effective == default);
#[case::configured("data_dir = \"/custom/data\"\n", "/custom/data")]
#[case::unset("", "/default/data")]
fn data_dir_prefers_the_configured_one(#[case] toml: &str, #[case] expected: &str) {
let settings: Settings = Settings::builder_with_data_dir(Path::new("/default/data"))
.unwrap()
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(settings.data_dir, Path::new(expected));
}
#[rstest]
@@ -2205,7 +2180,7 @@ mod tests {
/// Deserialize a TOML string into a [`Settings`] object.
fn parse_settings(toml: &str) -> Settings {
Settings::builder_with_data_dir(&atuin_common::utils::data_dir())
Settings::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())
.expect("could not build settings builder")
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
.build()
+80 -23
View File
@@ -76,6 +76,9 @@ impl Daemon {
///
/// This is the first path in [`Self::potential_socket_paths`] that exists, or if none exist,
/// the first path.
///
/// Note: you should almost always use `atuin_daemon::client::socket_path` instead of this
/// method, as it implements additional logic like checking the pidfile.
#[must_use]
pub fn existing_socket_path(&self) -> Cow<'_, Path> {
self.existing_socket_path_ctx(DefaultSocketCtx)
@@ -88,15 +91,29 @@ impl Daemon {
///
/// 1. If `systemd_socket` is true and `$XDG_RUNTIME_DIR` is non-empty,
/// `$XDG_RUNTIME_DIR/atuin.sock`.
/// 2. `/tmp/atuin-$UID/atuin.sock`.
/// 2. `$TMPDIR/atuin-$UID/atuin.sock` (substituting `/tmp` for `$TMPDIR` if unset).
/// 3. If `$XDG_RUNTIME_DIR` is non-empty, `$XDG_RUNTIME_DIR/atuin.sock` (legacy path), unless
/// step #1 already yielded that path.
/// 4. If `$XDG_DATA_HOME` is non-empty, `$XDG_DATA_HOME/atuin/atuin.sock` (legacy path).
/// 5. If `$XDG_DATA_HOME` is unset or empty, `~/.local/share/atuin/atuin.sock` (legacy path).
/// 6. If `$TMPDIR` is set, `/tmp/atuin-$UID/atuin.sock` (ignoring `$TMPDIR`).
///
/// Note: you should almost always use `atuin_daemon::client::potential_socket_paths` instead of
/// this method, as it implements additional logic.
pub fn potential_socket_paths(&self) -> impl Iterator<Item = Cow<'_, Path>> + use<'_> {
self.potential_socket_paths_ctx(DefaultSocketCtx)
}
/// The socket path that should be used when there is no existing socket.
///
/// This is the first element of [`Self::potential_socket_paths`].
#[must_use]
pub fn preferred_socket_path(&self) -> Cow<'_, Path> {
self.potential_socket_paths()
.next()
.expect("there is always at least one potential socket path")
}
fn socket_path_ctx(&self, ctx: impl SocketCtx) -> SocketPath<'_> {
if let Some(path) = &self.socket_path {
return SocketPath::UserDefined(path);
@@ -128,24 +145,53 @@ impl Daemon {
&self,
ctx: impl SocketCtx,
) -> impl Iterator<Item = Cow<'_, Path>> {
let is_user_defined = self.socket_path.is_some();
let defaults = (!is_user_defined)
.then(|| self.default_socket_paths(ctx))
.into_iter()
.flatten()
.map(Cow::Owned);
self.socket_path.as_deref().map(Cow::Borrowed).into_iter().chain(defaults)
if let Some(path) = self.socket_path.as_deref() {
itertools::Either::Left(std::iter::once(Cow::Borrowed(path)))
} else {
itertools::Either::Right(self.default_socket_paths(ctx).map(Cow::Owned))
}
}
fn default_socket_paths(&self, ctx: impl SocketCtx) -> impl Iterator<Item = PathBuf> {
let runtime_path = self.systemd_socket.then(|| ctx.runtime_socket_path()).flatten();
// If `runtime_socket_path` is `Some`, `ctx.legacy_socket_path()` will return the same path
// (both pointing to `$XDG_RUNTIME_DIR`), so don't include the legacy path in that case.
let legacy_path = runtime_path.is_none().then(|| ctx.legacy_socket_path());
let default_socket_path = ctx.default_socket_path();
[runtime_path, Some(default_socket_path.primary), legacy_path, default_socket_path.envless]
.into_iter()
.flatten()
#[derive(Clone, Copy, Eq, PartialEq)]
enum Step {
Runtime,
DefaultPrimary,
Legacy,
DefaultEnvless,
Done,
}
let mut step = Step::Runtime;
let mut has_runtime_path = false;
let mut default_envless = None;
std::iter::from_fn(move || {
let (next, item) = match step {
Step::Runtime => {
let runtime_path =
self.systemd_socket.then(|| ctx.runtime_socket_path()).flatten();
has_runtime_path = runtime_path.is_some();
(Step::DefaultPrimary, runtime_path)
}
Step::DefaultPrimary => {
let default_socket_path = ctx.default_socket_path();
default_envless = default_socket_path.envless;
(Step::Legacy, Some(default_socket_path.primary))
}
Step::Legacy => {
// If `runtime_path` was `Some`, `ctx.legacy_socket_path()` will return the same
// path (both pointing to `$XDG_RUNTIME_DIR`), so don't include the legacy path
// in that case.
(Step::DefaultEnvless, (!has_runtime_path).then(|| ctx.legacy_socket_path()))
}
Step::DefaultEnvless => (Step::Done, default_envless.take()),
Step::Done => return None,
};
step = next;
Some(item)
})
.flatten()
}
}
@@ -169,11 +215,11 @@ trait SocketCtx: Copy + Sized {
}
fn runtime_dir(&self) -> Option<PathBuf> {
atuin_common::utils::env_abspath("XDG_RUNTIME_DIR")
atuin_common::env::var_abspath("XDG_RUNTIME_DIR")
}
fn data_dir(&self) -> PathBuf {
atuin_common::utils::data_dir()
fn legacy_data_dir(&self) -> PathBuf {
atuin_common::dirs::legacy_data_dir()
}
fn uid(&self) -> std::ffi::c_uint {
@@ -182,7 +228,11 @@ trait SocketCtx: Copy + Sized {
fn default_socket_path(&self) -> EnvDependentPathBuf {
let subdir_name = format!("atuin-{}", self.uid());
let make_socket_path = |tmp: PathBuf| tmp.join(&subdir_name).join(SOCKET_NAME);
let make_socket_path = |mut tmp: PathBuf| {
tmp.push(&subdir_name);
tmp.push(SOCKET_NAME);
tmp
};
let tmp = self.tmp_dir();
let envless_tmp = self.envless_tmp_dir();
@@ -195,11 +245,18 @@ trait SocketCtx: Copy + Sized {
}
fn runtime_socket_path(&self) -> Option<PathBuf> {
self.runtime_dir().map(|dir| dir.join(SOCKET_NAME))
self.runtime_dir().map(|mut dir| {
dir.push(SOCKET_NAME);
dir
})
}
fn legacy_socket_path(&self) -> PathBuf {
self.runtime_socket_path().unwrap_or_else(|| self.data_dir().join(SOCKET_NAME))
self.runtime_socket_path().unwrap_or_else(|| {
let mut path = self.legacy_data_dir();
path.push(SOCKET_NAME);
path
})
}
}
@@ -354,7 +411,7 @@ mod unix_tests {
self.runtime_dir.map(Into::into)
}
fn data_dir(&self) -> PathBuf {
fn legacy_data_dir(&self) -> PathBuf {
"/home/user/.local/share/atuin".into()
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
let dir = atuin_common::utils::data_dir();
let dir = atuin_common::dirs::unconfigured_data_dir();
let path = dir.join("kv.db");
Self {
+1 -1
View File
@@ -7,7 +7,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
let dir = atuin_common::utils::data_dir();
let dir = atuin_common::dirs::unconfigured_data_dir();
let path = dir.join("meta.db");
Self {
+1 -1
View File
@@ -7,7 +7,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
let dir = atuin_common::utils::data_dir();
let dir = atuin_common::dirs::unconfigured_data_dir();
let path = dir.join("scripts.db");
Self {
+2 -2
View File
@@ -28,7 +28,7 @@ impl Shells {
/// allocating; see [`ShellFilter::as_filter`].
#[must_use]
pub fn to_filter(&self) -> ShellFilter<'_> {
self.to_filter_with(|| std::env::var("ATUIN_SHELL").ok())
self.to_filter_with(|| atuin_common::env::var("ATUIN_SHELL").ok())
}
/// Like [`Self::to_filter`], but takes the current shell as a parameter.
@@ -147,7 +147,7 @@ mod tests {
assert_eq!(result.as_ref().ok(), expected.as_ref(), "{result:?}");
}
#[test]
#[rstest]
fn all_and_the_empty_array_are_the_same_value() {
assert_eq!(parse(r#""all""#).unwrap(), parse("[]").unwrap());
assert_eq!(parse(r#""all""#).unwrap(), Shells::all());
+1 -6
View File
@@ -98,12 +98,7 @@ impl SettingsWatcher {
/// Get the config file path.
fn config_path() -> PathBuf {
let config_dir = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
PathBuf::from(p)
} else {
atuin_common::utils::config_dir()
};
config_dir.join("config.toml")
atuin_common::dirs::config_path("config.toml")
}
/// Create the file watcher with debouncing.
+56 -42
View File
@@ -1,14 +1,11 @@
use std::collections::HashMap;
use std::error;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
use std::sync::LazyLock;
use config::{Config, File as ConfigFile, FileFormat};
use palette::named;
use serde::{Deserialize, Serialize};
use serde_json;
use strum_macros;
static DEFAULT_MAX_DEPTH: u8 = 10;
@@ -356,56 +353,65 @@ static BUILTIN_THEMES: LazyLock<HashMap<&'static str, Theme>> = LazyLock::new(||
.collect()
});
/// Options for building a [`ThemeManager`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ThemeManagerOptions {
/// Show more detail about issues in theme files.
pub debug: bool,
/// Only load built-in themes, rather than reading the user's theme directory.
pub builtin_only: bool,
}
impl ThemeManagerOptions {
#[must_use]
pub fn from_settings(settings: &crate::settings::Theme) -> Self {
Self {
debug: settings.debug.unwrap_or(false),
builtin_only: false,
}
}
#[must_use]
pub fn build(self) -> ThemeManager {
ThemeManager {
loaded_themes: HashMap::new(),
debug: self.debug,
builtin_only: self.builtin_only,
}
}
}
// To avoid themes being repeatedly loaded, we store them in a theme manager
pub struct ThemeManager {
loaded_themes: HashMap<String, Theme>,
debug: bool,
override_theme_dir: Option<String>,
builtin_only: bool,
}
// Theme-loading logic
impl ThemeManager {
#[must_use]
pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
Self {
loaded_themes: HashMap::new(),
debug: debug.unwrap_or(false),
override_theme_dir: match theme_dir {
Some(theme_dir) => Some(theme_dir),
None => std::env::var("ATUIN_THEME_DIR").ok(),
},
}
pub fn from_settings(settings: &crate::settings::Theme) -> Self {
ThemeManagerOptions::from_settings(settings).build()
}
// Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set
// for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there
pub fn load_theme_from_file(
// Try to load a theme from a `{name}.toml` file in the themes directory (unless
// `Self::builtin_only` is true)
fn load_theme_from_file(
&mut self,
name: &str,
max_depth: u8,
) -> Result<&Theme, Box<dyn error::Error>> {
let mut theme_file = if let Some(p) = &self.override_theme_dir {
if p.is_empty() {
return Err(Box::new(Error::new(
ErrorKind::NotFound,
"Empty theme directory override and could not find theme elsewhere",
)));
}
PathBuf::from(p)
} else {
let config_dir = atuin_common::utils::config_dir();
let mut theme_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
PathBuf::from(p)
} else {
let mut theme_file = PathBuf::new();
theme_file.push(config_dir);
theme_file
};
theme_file.push("themes");
theme_file
};
if self.builtin_only {
return Err(Box::new(Error::new(
ErrorKind::NotFound,
"cannot load theme file: only built-in themes are enabled",
)));
}
let theme_toml = format!["{name}.toml"];
let mut theme_file = atuin_common::dirs::config_path("themes");
let theme_toml = format!("{name}.toml");
theme_file.push(theme_toml);
let mut config_builder = Config::builder();
@@ -499,7 +505,11 @@ mod theme_tests {
#[fixture]
fn manager(#[default(false)] debug: bool) -> ThemeManager {
ThemeManager::new(Some(debug), Some("".to_string()))
ThemeManagerOptions {
debug,
builtin_only: true,
}
.build()
}
fn theme_config(toml: &str) -> Config {
@@ -578,7 +588,7 @@ mod theme_tests {
assert_eq!(title_theme.as_style(Meaning::Title).foreground_color, Some(Color::White));
}
#[test]
#[rstest]
fn test_no_fallbacks_are_circular() {
let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
MEANING_FALLBACKS
@@ -670,8 +680,8 @@ mod theme_tests {
assert_eq!(captured_logs.len(), 1);
assert_eq!(
captured_logs[0].message,
"Could not load theme nonsolarized: Empty theme directory override and could not find \
theme elsewhere"
"Could not load theme nonsolarized: cannot load theme file: only built-in themes are \
enabled"
);
assert_eq!(captured_logs[0].level, tracing::Level::WARN);
}
@@ -680,7 +690,11 @@ mod theme_tests {
fn debug_theme_logs(#[values(true, false)] debug: bool) {
let logs = capture_logs();
let mut manager = ThemeManager::new(Some(debug), Some("".to_string()));
let mut manager = ThemeManagerOptions {
debug,
builtin_only: true,
}
.build();
let config = theme_config(
"
[theme]
+1 -1
View File
@@ -30,7 +30,7 @@ os = ["dep:rustix", "dep:windows-sys"]
proto = ["dep:prost"]
# Enables `atuin_common::db::sqlite`.
sqlite = ["db", "sqlx/sqlite", "sqlx/regexp", "dep:libsqlite3-sys", "dep:tokio-util"]
# Enables `atuin_common::test_utils`, for use in tests.
# Enables `atuin_common::test_utils` and `atuin_common::env::MockEnv`, for use in tests.
test-utils = ["dep:tracing-subscriber"]
# Enables various items in `atuin_common::string` that require Unicode data.
unicode = ["dep:unicode-width", "dep:unicode-segmentation"]
+767
View File
@@ -0,0 +1,767 @@
//! Provides the location of Atuin's files.
use std::path::{Path, PathBuf};
use crate::env::{var_abspath, var_nonempty};
/// Error returned when the user's home directory can't be determined.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[error("could not determine home directory")]
pub struct HomeError;
/// Get the user's home directory.
///
/// Example: `/home/ellie`
///
/// Returns an error if the user's home directory can't be determined.
pub fn try_home_dir() -> Result<PathBuf, HomeError> {
// `dirs::home_dir` doesn't go through this crate's env wrappers, so it can't be mocked. As
// a workaround, a mock environment's `HOME` is the only home there is.
#[cfg(any(test, feature = "test-utils"))]
if crate::env::mock::is_installed() {
return var_nonempty("HOME").map(PathBuf::from).ok_or(HomeError);
}
dirs::home_dir().ok_or(HomeError)
}
/// Variant of [`try_home_dir`] that panics instead of returning a [`HomeError`].
#[must_use]
pub fn home_dir() -> PathBuf {
try_home_dir().unwrap_or_else(|err| panic!("{err}"))
}
/// Represents the path to Atuin's home directory.
///
/// See [`atuin_home`].
#[derive(Clone, Debug, PartialEq, Eq, derive_more::Deref)]
pub enum AtuinHome {
/// The path came from `ATUIN_HOME`, which was nonempty and valid.
Set(PathBuf),
/// `ATUIN_HOME` was unset, so this is the default `~/.atuin`.
Default(PathBuf),
}
impl AsRef<Path> for AtuinHome {
fn as_ref(&self) -> &Path {
self
}
}
impl From<AtuinHome> for PathBuf {
fn from(home: AtuinHome) -> Self {
match home {
AtuinHome::Set(path) | AtuinHome::Default(path) => path,
}
}
}
/// Get Atuin's home directory.
///
/// All Atuin data should be stored in this directory. By default, this is `~/.atuin`, but can
/// be overridden with the `ATUIN_HOME` environment variable, which must be an absolute path.
///
/// Returns an error if `ATUIN_HOME` is unset/invalid and the user's home directory can't be
/// determined for the default case.
pub fn try_atuin_home() -> Result<AtuinHome, HomeError> {
if let Some(value) = var_abspath("ATUIN_HOME") {
return Ok(AtuinHome::Set(value));
}
let mut path = try_home_dir()?;
path.push(".atuin");
Ok(AtuinHome::Default(path))
}
/// Variant of [`try_atuin_home`] that panics instead of returning a [`HomeError`].
#[must_use]
pub fn atuin_home() -> AtuinHome {
try_atuin_home().unwrap_or_else(|err| panic!("{err}"))
}
/// Check whether `ATUIN_HOME` is set and valid (is an absolute path).
///
/// This function is slightly more efficient than checking the return value of [`try_atuin_home`].
#[must_use]
pub fn atuin_home_is_set() -> bool {
var_abspath("ATUIN_HOME").is_some()
}
/// Top-level config items affected by the `ATUIN_CONFIG_DIR` environment variable.
const ATUIN_CONFIG_DIR_ITEMS: [&str; 2] = ["config.toml", "server.toml"];
/// Top-level config items that Atuin AI stores in both project-level `.atuin` directories as well
/// as the global `~/.atuin`.
///
/// Before the switch to [`atuin_home`], a file like `~/.atuin/permissions.ai.toml` was scoped to
/// `$HOME`, like any other project directory. Now, `~/.atuin/permissions.ai.toml` is the new
/// location for the *global* permissions file. [`config_path`] implements logic to avoid
/// reinterpreting old `$HOME`-scoped files as global ones; this array contains all the affected
/// items.
const AI_ITEMS: [&str; 3] = ["permissions.ai.toml", "skills", "TERMINAL.md"];
/// Get the path of a config item.
///
/// `child` is relative to the config directory. The final path is determined as follows:
///
/// 1. If `child` is `config.toml` or `server.toml` and `ATUIN_CONFIG_DIR` is set,
/// `$ATUIN_CONFIG_DIR/child` is returned.
/// 2. If `ATUIN_HOME` is set and valid (an absolute path), `$ATUIN_HOME/child` is returned.
/// 3. If the first component of `child` is `permissions.ai.toml`, `skills`, or `TERMINAL.md`, and
/// `~/.atuin/config.toml` doesn't exist, `legacy_config_dir()/child`[^1] is returned.
/// 4. If the first component of `child` exists in `~/.atuin`, `~/.atuin/child` is returned.
/// 5. If the first component of `child` is `themes` and `ATUIN_THEME_DIR` is set,
/// `$ATUIN_THEME_DIR/child_tail` is returned, where `child_tail` is all of `child` except the
/// first component. This behavior is deprecated and exists only for backward compatibility.
/// 6. If the first component of `child` is `themes` and `ATUIN_CONFIG_DIR` is set,
/// `$ATUIN_CONFIG_DIR/themes` is returned. This behavior is deprecated and exists only for
/// backward compatibility.
/// 7. If the first component of `child` exists in `legacy_config_dir()`[^1],
/// `legacy_config_dir()/child` is returned.
/// 8. Otherwise, `~/.atuin/child` is returned.
///
/// When these steps refer to checking the existence of a path that depends on the user's home
/// directory, like `~/.atuin/config.toml`, an inability to determine the user's home directory is
/// treated the same as the path not existing.
///
/// # Panics
///
/// Panics if `child` is empty or absolute, or if it starts with `.` or `..`. Also panics if the
/// user's home directory can't be determined and the path can't be calculated without it, which can
/// happen if step 8 is reached, or if the condition in step 3 is true.
///
/// [^1]: `legacy_config_dir()` is `$XDG_CONFIG_HOME/atuin`, or `~/.config/atuin` if unset.
#[must_use]
pub fn config_path(child: impl AsRef<Path>) -> PathBuf {
let child = child.as_ref();
let Some(std::path::Component::Normal(top)) = child.components().next() else {
panic!("config path must be relative and start with a top-level item: {child:?}");
};
if ATUIN_CONFIG_DIR_ITEMS.iter().any(|name| *name == top)
&& let Some(value) = var_nonempty("ATUIN_CONFIG_DIR")
{
let mut path = PathBuf::from(value);
path.push(child);
return path;
}
let home = match try_atuin_home() {
Ok(AtuinHome::Set(mut path)) => {
path.push(child);
return path;
}
Ok(AtuinHome::Default(path)) => Ok(path),
Err(e) => Err(e),
};
// The existence of `~/.atuin/config.toml` distinguishes new installations from old ones. With
// `ATUIN_HOME` unset, if `~/.atuin/config.toml` is missing (indicating an old installation),
// continue to use the legacy config dir for AI-related items, to avoid interpreting an existing
// `~/.atuin/permissions.ai.toml`, which was previously scoped to `$HOME`, as the global file.
if AI_ITEMS.iter().any(|name| *name == top)
&& !matches!(&home, Ok(home) if home.join("config.toml").exists())
{
let mut path = legacy_config_dir().unwrap_or_else(|err| panic!("{err}"));
path.push(child);
return path;
}
let home = match home {
Ok(mut path) => {
path.push(top);
if path.exists() {
path.pop(); // pop `top` from the path
path.push(child);
return path;
}
path.pop(); // pop `top` from the path
Ok(path)
}
Err(e) => Err(e),
};
let is_themes = top == "themes";
if is_themes && let Some(value) = var_nonempty("ATUIN_THEME_DIR") {
// For backward compatibility, keep respecting the deprecated `ATUIN_THEME_DIR` variable,
// but only if `ATUIN_HOME` is unset and `~/.atuin/themes` doesn't exist.
let mut path = PathBuf::from(value);
path.extend(child.components().skip(1));
return path;
}
// Potential directories that might contain the config item. The first one that contains the
// item, if any, is used.
let potential_dirs = [
// Historically, `ATUIN_CONFIG_DIR` affected the location of `config.toml`, `server.toml`,
// and `themes`, but not any other config items such as `skills` or `permissions.ai.toml`.
// This behavior has been simplified: `ATUIN_CONFIG_DIR` only affects `config.toml` and
// `server.toml`; using it to control `themes` is deprecated.
//
// For backward compatibility, read themes from `$ATUIN_CONFIG_DIR/themes`, but only if
// `ATUIN_HOME` is unset and `~/.atuin/themes` doesn't exist.
is_themes.then(|| var_nonempty("ATUIN_CONFIG_DIR")).flatten().map(Into::into),
// Atuin used to store config items in `$XDG_CONFIG_HOME/atuin`. For backward compatibility,
// if `ATUIN_HOME` is unset, continue to read items from there when they don't exist in
// `~/.atuin`.
legacy_config_dir().ok(),
];
if let Some(mut existing) = potential_dirs
.into_iter()
.flatten()
.map(|mut dir| {
dir.push(top);
dir
})
.find(|dir| dir.exists())
{
existing.pop(); // pop `top` from the path
existing.push(child);
return existing;
}
// At this point, we cannot continue without being able to determine the user's home directory.
let mut path = home.unwrap_or_else(|err| panic!("{err}"));
path.push(child);
path
}
/// Get the legacy config directory, used before the switch to [`atuin_home`].
///
/// This is `$XDG_CONFIG_HOME/atuin`, or `~/.config/atuin` if unset.
fn legacy_config_dir() -> Result<PathBuf, HomeError> {
let mut path = if let Some(path) = var_abspath("XDG_CONFIG_HOME") {
path
} else {
let mut path = try_home_dir()?;
path.push(".config");
path
};
path.push("atuin");
Ok(path)
}
/// Get the data directory used when `data_dir` isn't set in config.toml.
///
/// This is `$ATUIN_HOME/data`, but will fall back to [`legacy_data_dir`] if only that path exists
/// and `ATUIN_HOME` is unset, for compatibility with older installations.
///
/// You should almost always use `atuin_client::settings::Settings::data_dir` instead, since that
/// respects `data_dir` in config.toml.
#[must_use]
pub fn unconfigured_data_dir() -> PathBuf {
match atuin_home() {
AtuinHome::Set(mut path) => {
path.push("data");
path
}
AtuinHome::Default(mut path) => {
path.push("data");
if !path.exists() {
let old_path = legacy_data_dir();
if old_path.exists() {
return old_path;
}
}
path
}
}
}
/// Get the legacy data directory, used before the switch to [`atuin_home`].
///
/// This is `$XDG_DATA_HOME/atuin`, or `~/.local/share/atuin` if unset.
#[must_use]
pub fn legacy_data_dir() -> PathBuf {
let mut path = var_abspath("XDG_DATA_HOME").unwrap_or_else(|| {
let mut path = home_dir();
path.push(".local");
path.push("share");
path
});
path.push("atuin");
path
}
/// Get the logs directory.
///
/// This is `$ATUIN_HOME/logs`.
#[must_use]
pub fn logs_dir() -> PathBuf {
let mut path = PathBuf::from(atuin_home());
path.push("logs");
path
}
#[must_use]
pub fn dotfiles_cache_dir() -> PathBuf {
// atuin-dotfiles was removed before the switch to `atuin_home`. It always stored the dotfiles
// cache in ~/.local/share/atuin, ignoring the `data_dir` setting in config.toml.
// `unconfigured_data_dir` will fall back to the legacy data dir when `ATUIN_HOME` is unset
// and ~/.atuin/data doesn't exist, so we will continue looking for the dotfiles cache in the
// same location. The reason we're using `unconfigured_data_dir` instead of calling
// `legacy_data_dir` directly is in case a user has manually moved ~/.local/share/atuin to
// ~/.atuin/data to migrate to the new layout -- that will move the dotfiles cache too.
//
// It would potentially be more correct to check `atuin_common::settings::Settings::data_dir`
// first, since that would respect `data_dir` in config.toml, but that would involve significant
// changes to atuin-dotfiles. Not worth it for a removed feature.
let mut path = unconfigured_data_dir();
path.push("dotfiles");
path.push("cache");
path
}
#[cfg(test)]
mod tests {
use rstest::{fixture, rstest};
use super::*;
use crate::env::MockEnv;
#[cfg(not(windows))]
#[rstest]
fn test_legacy_config_dir_xdg(env: MockEnv) {
env.set("XDG_CONFIG_HOME", "/home/user/custom_config");
assert_eq!(legacy_config_dir(), Ok(PathBuf::from("/home/user/custom_config/atuin")));
}
/// An empty `XDG_CONFIG_HOME` has to be treated as unset: the alternative is a relative path.
#[cfg(not(windows))]
#[rstest]
fn test_legacy_config_dir_xdg_empty(env: MockEnv) {
env.set("HOME", "/home/user");
env.set("XDG_CONFIG_HOME", "");
assert_eq!(legacy_config_dir(), Ok(PathBuf::from("/home/user/.config/atuin")));
}
#[cfg(not(windows))]
#[rstest]
fn test_legacy_config_dir(env: MockEnv) {
env.set("HOME", "/home/user");
assert_eq!(legacy_config_dir(), Ok(PathBuf::from("/home/user/.config/atuin")));
}
#[cfg(not(windows))]
#[rstest]
fn test_legacy_data_dir_xdg(env: MockEnv) {
env.set("XDG_DATA_HOME", "/home/user/custom_data");
assert_eq!(legacy_data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
}
/// An empty `XDG_DATA_HOME` has to be treated as unset: the alternative is a relative path.
#[cfg(not(windows))]
#[rstest]
fn test_legacy_data_dir_xdg_empty(env: MockEnv) {
env.set("HOME", "/home/user");
env.set("XDG_DATA_HOME", "");
assert_eq!(legacy_data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
}
#[cfg(not(windows))]
#[rstest]
fn test_legacy_data_dir(env: MockEnv) {
env.set("HOME", "/home/user");
assert_eq!(legacy_data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
}
#[cfg(not(windows))]
#[rstest]
fn test_atuin_home(env: MockEnv) {
env.set("HOME", "/home/user");
assert_eq!(atuin_home(), AtuinHome::Default("/home/user/.atuin".into()));
}
/// An empty `ATUIN_HOME` has to be treated as unset: the alternative is the working directory.
#[cfg(not(windows))]
#[rstest]
fn test_atuin_home_empty(env: MockEnv) {
env.set("HOME", "/home/user");
env.set("ATUIN_HOME", "");
assert_eq!(atuin_home(), AtuinHome::Default("/home/user/.atuin".into()));
}
#[cfg(not(windows))]
#[rstest]
fn test_atuin_home_absolute(env: MockEnv) {
env.set("ATUIN_HOME", "/profiles/work");
assert_eq!(atuin_home(), AtuinHome::Set("/profiles/work".into()));
}
/// A relative `ATUIN_HOME` is ignored: it would resolve differently depending on the working
/// directory, so the daemon and the client could end up using different homes.
#[cfg(not(windows))]
#[rstest]
fn test_atuin_home_relative(env: MockEnv) {
env.set("HOME", "/home/user");
env.set("ATUIN_HOME", "profile");
assert_eq!(atuin_home(), AtuinHome::Default("/home/user/.atuin".into()));
}
#[rstest]
#[case::set(AtuinHome::Set("/atuin".into()))]
#[case::default(AtuinHome::Default("/atuin".into()))]
fn atuin_home_converts_to_its_path(#[case] home: AtuinHome) {
let expected = Path::new("/atuin");
assert_eq!(home.as_ref(), expected);
assert_eq!(home.join("data"), expected.join("data"));
assert_eq!(PathBuf::from(home), expected);
}
/// A mock environment, in which every variable starts out unset.
#[fixture]
fn env() -> MockEnv {
MockEnv::install()
}
/// `$HOME` and the legacy XDG directories pointed into a temporary directory, which is
/// removed on drop. `$ATUIN_HOME` is unset unless a test sets it with
/// [`Homes::set_atuin_home`].
struct Homes {
tmp: tempfile::TempDir,
env: MockEnv,
}
impl Homes {
/// The default `$ATUIN_HOME`, `~/.atuin`.
fn new_home(&self) -> PathBuf {
self.tmp.path().join("home").join(".atuin")
}
/// Set `$ATUIN_THEME_DIR` to `value`.
fn set_theme_dir(&self, value: &Path) {
self.env.set("ATUIN_THEME_DIR", value);
}
/// Set `$ATUIN_HOME` to a directory other than [`Self::new_home`], and return it.
fn set_atuin_home(&self) -> PathBuf {
let path = self.tmp.path().join("profile");
self.env.set("ATUIN_HOME", &path);
path
}
fn legacy_config(&self) -> PathBuf {
self.tmp.path().join("xdg-config").join("atuin")
}
/// Create `~/.atuin/config.toml`, which marks an installation that uses `~/.atuin` (a new
/// one, or one that has moved there) rather than the legacy config dir.
fn migrate(&self) {
create(&self.new_home().join("config.toml"));
}
fn legacy_data(&self) -> PathBuf {
self.tmp.path().join("xdg-data").join("atuin")
}
fn config_path(&self, child: &str, atuin_config_dir: Option<&str>) -> PathBuf {
match atuin_config_dir {
Some(dir) => self.env.set("ATUIN_CONFIG_DIR", dir),
None => self.env.remove("ATUIN_CONFIG_DIR"),
}
config_path(child)
}
}
/// Create `path` as a file if it has an extension, else as a directory.
fn create(path: &Path) {
if path.extension().is_some() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
} else {
std::fs::create_dir_all(path).unwrap();
}
}
#[fixture]
fn homes(env: MockEnv) -> Homes {
let tmp = tempfile::tempdir().unwrap();
env.set("HOME", tmp.path().join("home"));
env.set("XDG_CONFIG_HOME", tmp.path().join("xdg-config"));
env.set("XDG_DATA_HOME", tmp.path().join("xdg-data"));
Homes { tmp, env }
}
#[rstest]
fn unconfigured_data_dir_is_new_by_default(homes: Homes) {
let expected = homes.new_home().join("data");
assert_eq!(unconfigured_data_dir(), expected);
}
#[rstest]
fn unconfigured_data_dir_is_legacy_when_only_legacy_exists(homes: Homes) {
create(&homes.legacy_data());
// `$ATUIN_HOME` itself existing (e.g. for logs) doesn't count.
create(&homes.new_home().join("logs"));
assert_eq!(unconfigured_data_dir(), homes.legacy_data());
}
#[rstest]
fn unconfigured_data_dir_is_new_when_both_exist(homes: Homes) {
create(&homes.legacy_data());
create(&homes.new_home().join("data"));
let expected = homes.new_home().join("data");
assert_eq!(unconfigured_data_dir(), expected);
}
#[rstest]
fn unconfigured_data_dir_ignores_legacy_when_atuin_home_is_set(homes: Homes) {
create(&homes.legacy_data());
let atuin_home = homes.set_atuin_home();
assert_eq!(unconfigured_data_dir(), atuin_home.join("data"));
}
#[rstest]
fn config_items_are_resolved_independently(
homes: Homes,
#[values(
"config.toml",
"server.toml",
"TERMINAL.md",
"permissions.ai.toml",
"skills",
"themes"
)]
item: &str,
#[values(false, true)] in_legacy: bool,
#[values(false, true)] in_new: bool,
#[values(false, true)] migrated: bool,
) {
// A sibling item living in the legacy dir mustn't drag this one along with it, nor the
// other way round.
create(&homes.legacy_config().join("sibling.toml"));
create(&homes.new_home().join("other.toml"));
if in_legacy {
create(&homes.legacy_config().join(item));
}
if in_new {
create(&homes.new_home().join(item));
}
if migrated {
homes.migrate();
}
let in_new = in_new || (migrated && item == "config.toml");
// Until the installation uses `~/.atuin`, Atuin AI's items stay in the legacy dir.
let base = if (AI_ITEMS.contains(&item) && !migrated) || (in_legacy && !in_new) {
homes.legacy_config()
} else {
homes.new_home()
};
assert_eq!(homes.config_path(item, None), base.join(item));
}
#[rstest]
fn nested_config_paths_are_resolved_by_their_top_level_item(homes: Homes) {
homes.migrate();
create(&homes.legacy_config().join("themes"));
assert_eq!(
homes.config_path("themes/dark.toml", None),
homes.legacy_config().join("themes/dark.toml")
);
assert_eq!(
homes.config_path("skills/foo/SKILL.md", None),
homes.new_home().join("skills/foo/SKILL.md")
);
}
/// Before the switch to `~/.atuin`, `~/.atuin/permissions.ai.toml` held approvals for the home
/// directory only. Until the installation uses `~/.atuin`, it must not become the global file,
/// even when there's no legacy global file: that would make its approvals apply everywhere.
#[rstest]
fn an_old_home_scoped_ai_file_is_not_the_global_one(
homes: Homes,
#[values("permissions.ai.toml", "TERMINAL.md", "skills")] item: &str,
) {
create(&homes.new_home().join(item));
assert_eq!(homes.config_path(item, None), homes.legacy_config().join(item));
// Once the installation uses `~/.atuin`, that's where they are.
homes.migrate();
assert_eq!(homes.config_path(item, None), homes.new_home().join(item));
}
/// Atuin AI's items stay in the legacy dir without a home directory, which works when
/// `XDG_CONFIG_HOME` says where that is.
#[rstest]
fn ai_items_do_without_a_home_dir_given_xdg_config_home(
env: MockEnv,
#[values("permissions.ai.toml", "TERMINAL.md", "skills/foo/SKILL.md")] child: &str,
) {
let config: PathBuf = if cfg!(windows) {
"c:\\xdg-config"
} else {
"/xdg-config"
}
.into();
env.set("XDG_CONFIG_HOME", &config);
assert_eq!(try_home_dir(), Err(HomeError));
assert_eq!(config_path(child), config.join("atuin").join(child));
}
/// Which Atuin home an installation uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Install {
/// `ATUIN_HOME` is set.
AtuinHomeSet,
/// The default `~/.atuin`, on an installation from before it, which hasn't moved there.
Legacy,
/// The default `~/.atuin`, on a new installation or one that has moved there.
Migrated,
}
#[rstest]
fn config_items_are_looked_up_in_priority_order(
homes: Homes,
// Whether each item is one `$ATUIN_CONFIG_DIR` relocates, whether it is a theme, which is
// still read from there for backward compatibility, and whether it is one of Atuin AI's,
// which stay in the legacy dir until the installation uses `~/.atuin`.
#[values(
("config.toml", true, false, false),
("server.toml", true, false, false),
("themes/dark.toml", false, true, false),
("permissions.ai.toml", false, false, true),
("TERMINAL.md", false, false, true),
("skills/foo/SKILL.md", false, false, true)
)]
(child, relocated, theme, ai): (&str, bool, bool, bool),
#[values(false, true)] config_dir_set: bool,
#[values(false, true)] in_config_dir: bool,
#[values(Install::AtuinHomeSet, Install::Legacy, Install::Migrated)] install: Install,
#[values(false, true)] in_home: bool,
#[values(false, true)] in_legacy: bool,
) {
let atuin_home_set = install == Install::AtuinHomeSet;
let migrated = install == Install::Migrated;
let home = if atuin_home_set {
homes.set_atuin_home()
} else {
homes.new_home()
};
let config_dir = homes.tmp.path().join("config-dir");
for (dir, present) in [
(config_dir.clone(), in_config_dir),
(home.clone(), in_home),
(homes.legacy_config(), in_legacy),
] {
if present {
create(&dir.join(child));
}
}
if migrated {
homes.migrate();
}
let in_home = in_home || (migrated && !atuin_home_set && child == "config.toml");
// A set `ATUIN_HOME` is used whether or not the item exists there; the fallbacks only
// apply to the default `~/.atuin`.
let base = if config_dir_set && relocated {
config_dir.clone()
} else if atuin_home_set {
home
} else if ai && !migrated {
homes.legacy_config()
} else if in_home {
home
} else if theme && config_dir_set && in_config_dir {
config_dir.clone()
} else if in_legacy {
homes.legacy_config()
} else {
home
};
let config_dir = config_dir_set.then(|| config_dir.to_str().unwrap());
assert_eq!(homes.config_path(child, config_dir), base.join(child));
}
/// `ATUIN_THEME_DIR` is only a fallback for the default `~/.atuin`, when it has no themes; a
/// set `ATUIN_HOME` is used for themes whether or not it has any.
#[rstest]
fn atuin_theme_dir_is_used_only_without_atuin_home_or_its_themes(
homes: Homes,
#[values(("themes", ""), ("themes/dark.toml", "dark.toml"))] (child, within): (&str, &str),
#[values(false, true)] atuin_home_set: bool,
#[values(false, true)] in_home: bool,
) {
let home = if atuin_home_set {
homes.set_atuin_home()
} else {
homes.new_home()
};
// The themes exist in `$ATUIN_CONFIG_DIR` and the legacy dir, but not in
// `$ATUIN_THEME_DIR`.
let config_dir = homes.tmp.path().join("config-dir");
for dir in [&config_dir, &homes.legacy_config()] {
create(&dir.join("themes"));
}
if in_home {
create(&home.join("themes"));
}
let theme_dir = homes.tmp.path().join("theme-dir");
homes.set_theme_dir(&theme_dir);
let expected = if atuin_home_set || in_home {
home.join(child)
} else {
theme_dir.join(within)
};
let config_dir = config_dir.to_str().unwrap();
assert_eq!(homes.config_path(child, Some(config_dir)), expected);
}
#[rstest]
fn atuin_theme_dir_is_ignored_for_other_items(
homes: Homes,
#[values("config.toml", "TERMINAL.md", "skills/foo/SKILL.md")] child: &str,
) {
homes.migrate();
homes.set_theme_dir(&homes.tmp.path().join("theme-dir"));
assert_eq!(homes.config_path(child, None), homes.new_home().join(child));
}
/// An empty `ATUIN_THEME_DIR` has to be treated as unset.
#[rstest]
fn empty_atuin_theme_dir_is_unset(homes: Homes) {
homes.set_theme_dir(Path::new(""));
assert_eq!(
homes.config_path("themes/dark.toml", None),
homes.new_home().join("themes/dark.toml")
);
}
/// Items that don't need the home directory are still found when it can't be determined.
#[rstest]
#[case::atuin_config_dir(
"config.toml",
"ATUIN_CONFIG_DIR",
"/etc/atuin",
"/etc/atuin/config.toml"
)]
#[case::atuin_theme_dir("themes/dark.toml", "ATUIN_THEME_DIR", "/themes", "/themes/dark.toml")]
fn config_path_does_without_a_home_dir_where_it_can(
env: MockEnv,
#[case] child: &str,
#[case] var: &str,
#[case] dir: &str,
#[case] expected: &str,
) {
env.set(var, dir);
assert_eq!(try_home_dir(), Err(HomeError));
assert_eq!(config_path(child), Path::new(expected));
}
#[rstest]
#[should_panic(expected = "could not determine home directory")]
fn config_path_panics_without_a_home_dir_as_a_last_resort() {
let _env = MockEnv::install();
let _ = config_path("skills");
}
#[rstest]
#[case::empty("")]
#[case::absolute("/etc/config.toml")]
#[case::parent("../config.toml")]
#[should_panic(expected = "must be relative")]
fn config_path_rejects_non_items(homes: Homes, #[case] child: &str) {
let _ = homes.config_path(child, None);
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Utilities for reading environment variables.
use std::env::VarError;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
/// Wrapper around [`std::env::var_os`] that can be mocked in tests.
#[must_use]
pub fn var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
#[cfg(any(test, feature = "test-utils"))]
if let Some(value) = mock::get_os(key.as_ref()) {
return value;
}
#[expect(clippy::disallowed_methods, reason = "this is the wrapper the lint references")]
std::env::var_os(key)
}
/// Wrapper around [`std::env::var`] that can be mocked in tests.
pub fn var(key: impl AsRef<OsStr>) -> Result<String, VarError> {
#[cfg(any(test, feature = "test-utils"))]
if let Some(value) = mock::get(key.as_ref()) {
return value;
}
#[expect(clippy::disallowed_methods, reason = "this is the wrapper the lint references")]
std::env::var(key)
}
/// Read an environment variable that must be nonempty.
///
/// This function will never return an empty string: if the environment variable is set but empty,
/// [`None`] is returned.
#[must_use]
pub fn var_nonempty(name: &str) -> Option<OsString> {
var_os(name).filter(|value| !value.is_empty())
}
/// Read an environment variable that must be an absolute path.
///
/// This is usually done in the name of XDG-compliance which requires that paths given through
/// environment variables are absolute.
pub fn var_abspath(name: &str) -> Option<PathBuf> {
let path = PathBuf::from(var_nonempty(name)?);
if !path.is_absolute() {
tracing::warn!("ignoring relative path in {name}: {}", path.display());
return None;
}
Some(path)
}
#[cfg(any(test, feature = "test-utils"))]
pub use mock::MockEnv;
#[cfg(any(test, feature = "test-utils"))]
pub(crate) mod mock {
use std::cell::RefCell;
use std::collections::HashMap;
use std::env::VarError;
use std::ffi::{OsStr, OsString};
use std::marker::PhantomData;
thread_local! {
/// The current thread's mock environment, if one is installed.
static MOCK: RefCell<Option<HashMap<OsString, OsString>>> = const { RefCell::new(None) };
}
/// Whether the current thread has a mock environment installed.
pub fn is_installed() -> bool {
MOCK.with_borrow(Option::is_some)
}
pub(super) fn get_os(key: &OsStr) -> Option<Option<OsString>> {
MOCK.with_borrow(|mock| mock.as_ref().map(|vars| vars.get(key).cloned()))
}
pub(super) fn get(key: &OsStr) -> Option<Result<String, VarError>> {
get_os(key).map(|value| {
value.ok_or(VarError::NotPresent)?.into_string().map_err(VarError::NotUnicode)
})
}
/// A mock environment for the current thread, in which every variable starts out unset.
///
/// While it is installed, [`var`](super::var) and [`var_os`](super::var_os) read from it
/// instead of the real environment. It is uninstalled on drop.
#[derive(Debug)]
pub struct MockEnv {
/// The mock belongs to the thread that installed it, so the guard has to stay there.
_not_send: PhantomData<*const ()>,
}
impl MockEnv {
/// Install an empty mock environment for the current thread.
///
/// # Panics
///
/// If the current thread already has one.
#[must_use]
pub fn install() -> Self {
MOCK.with_borrow_mut(|mock| {
assert!(mock.is_none(), "a mock environment is already installed on this thread");
*mock = Some(HashMap::new());
});
Self {
_not_send: PhantomData,
}
}
pub fn set(&self, key: impl Into<OsString>, value: impl Into<OsString>) {
Self::with_vars(|vars| vars.insert(key.into(), value.into()));
}
pub fn remove(&self, key: impl AsRef<OsStr>) {
Self::with_vars(|vars| vars.remove(key.as_ref()));
}
fn with_vars<T>(f: impl FnOnce(&mut HashMap<OsString, OsString>) -> T) -> T {
MOCK.with_borrow_mut(|mock| {
f(mock.as_mut().expect("the mock environment is installed"))
})
}
}
impl Drop for MockEnv {
fn drop(&mut self) {
MOCK.with_borrow_mut(|mock| *mock = None);
}
}
}
#[cfg(test)]
mod tests {
use rstest::{fixture, rstest};
use super::*;
#[fixture]
fn env() -> MockEnv {
MockEnv::install()
}
#[rstest]
fn a_mock_starts_out_empty() {
let _env = MockEnv::install();
// `PATH` is set in practically every real environment.
assert_eq!(var_os("PATH"), None);
assert_eq!(var("PATH"), Err(VarError::NotPresent));
}
#[rstest]
fn set_and_removed_variables_are_read_from_the_mock(env: MockEnv) {
env.set("ATUIN_TEST_VAR", "value");
assert_eq!(var_os("ATUIN_TEST_VAR"), Some("value".into()));
assert_eq!(var("ATUIN_TEST_VAR").as_deref(), Ok("value"));
env.remove("ATUIN_TEST_VAR");
assert_eq!(var_os("ATUIN_TEST_VAR"), None);
}
#[cfg(unix)]
#[rstest]
fn non_unicode_values_are_reported_like_std(env: MockEnv) {
use std::os::unix::ffi::OsStringExt;
let value = OsString::from_vec(vec![0xff]);
env.set("ATUIN_TEST_VAR", value.clone());
assert_eq!(var("ATUIN_TEST_VAR"), Err(VarError::NotUnicode(value)));
}
#[rstest]
fn the_real_environment_is_read_without_a_mock() {
assert!(var_os("PATH").is_some());
}
#[rstest]
fn dropping_the_mock_uninstalls_it() {
drop(MockEnv::install());
assert!(var_os("PATH").is_some());
// And another can be installed.
drop(MockEnv::install());
}
#[rstest]
#[case::empty("", None)]
#[case::nonempty("value", Some("value"))]
fn var_nonempty_treats_empty_as_unset(
env: MockEnv,
#[case] value: &str,
#[case] expected: Option<&str>,
) {
env.set("ATUIN_TEST_VAR", value);
assert_eq!(var_nonempty("ATUIN_TEST_VAR"), expected.map(OsString::from));
}
#[cfg(unix)]
#[rstest]
#[case::absolute("/tmp/atuin", Some("/tmp/atuin"))]
#[case::relative("tmp/atuin", None)]
#[case::empty("", None)]
fn var_abspath_ignores_relative_paths(
env: MockEnv,
#[case] value: &str,
#[case] expected: Option<&str>,
) {
env.set("ATUIN_TEST_VAR", value);
assert_eq!(var_abspath("ATUIN_TEST_VAR"), expected.map(PathBuf::from));
}
}
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use super::rehydrate::{RehydrateError, RehydrateSession};
use super::resume::{self, CwdRequirement, ResumeError, ResumePlan, ResumeTarget};
use super::{Harness, InstallHookError, json_hooks};
use crate::utils::home_dir;
use crate::dirs::home_dir;
pub mod rehydrate;
pub mod session;
@@ -10,6 +10,8 @@ use time::format_description::well_known::Rfc3339;
use tokio::sync::watch;
use typed_builder::TypedBuilder;
use crate::dirs::home_dir;
use crate::env::var_nonempty;
use crate::fs::tree_watcher::{FileStat, TreeWatcher};
use crate::harnesstools::ccode::Ccode;
use crate::harnesstools::resume;
@@ -24,7 +26,6 @@ use crate::harnesstools::session::{
use crate::io::{FollowLines, Line, PathLineReader, PooledReadLines};
use crate::json::jsonl::JsonlExt;
use crate::sync::BlockingPool;
use crate::utils::{env_nonempty, home_dir};
#[derive(Debug, Clone, TypedBuilder)]
pub struct CcodeSessions {
@@ -43,7 +44,7 @@ impl CcodeSessions {
/// Claude Code's projects directory: `$CLAUDE_CONFIG_DIR/projects`, else
/// `~/.claude/projects`.
pub(crate) fn default_root() -> PathBuf {
env_nonempty("CLAUDE_CONFIG_DIR")
var_nonempty("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".claude"))
.join("projects")
@@ -3,7 +3,8 @@ use std::path::PathBuf;
use super::rehydrate::{RehydrateError, RehydrateSession};
use super::resume::{self, CwdRequirement, ResumeError, ResumePlan, ResumeTarget};
use super::{Harness, InstallHookError, json_hooks};
use crate::utils::{env_nonempty, home_dir};
use crate::dirs::home_dir;
use crate::env::var_nonempty;
mod hook_trust;
pub mod rehydrate;
@@ -12,7 +13,7 @@ pub(crate) mod state_db;
/// Codex's home: `$CODEX_HOME`, else `~/.codex`.
pub(crate) fn home() -> PathBuf {
env_nonempty("CODEX_HOME").map_or_else(|| home_dir().join(".codex"), PathBuf::from)
var_nonempty("CODEX_HOME").map_or_else(|| home_dir().join(".codex"), PathBuf::from)
}
#[derive(Debug, Clone, Copy, Default)]
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
use super::rehydrate::{RehydrateError, RehydrateSession};
use super::resume::{self, CwdRequirement, ResumeError, ResumePlan, ResumeTarget};
use super::{Harness, InstallHookError};
use crate::utils::{env_nonempty, home_dir};
use crate::dirs::home_dir;
use crate::env::var_nonempty;
pub mod rehydrate;
pub mod session;
@@ -44,7 +45,7 @@ impl Harness for Opencode {
// Opencode is XDG-compliant: plugins live under $XDG_CONFIG_HOME/opencode,
// falling back to ~/.config/opencode.
let plugins_dir = env_nonempty("XDG_CONFIG_HOME")
let plugins_dir = var_nonempty("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("opencode")
@@ -167,7 +168,7 @@ await fire("tool.execute.after", { tool: "read", id: "c6", status: "completed",
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(dir.path().join("atuin.ts"), PLUGIN_2).unwrap();
std::fs::write(dir.path().join("driver.mts"), DRIVER).unwrap();
let path = std::env::var("PATH").unwrap_or_default();
let path = crate::env::var("PATH").unwrap_or_default();
let ran = std::process::Command::new("node")
.arg("driver.mts")
@@ -109,6 +109,8 @@ use crate::db::sqlite::observe::{
TableSchema, Tailable,
};
use crate::db::{query_as, query_scalar};
use crate::dirs::home_dir;
use crate::env::var_nonempty;
use crate::harnesstools::opencode::Opencode;
use crate::harnesstools::session::model::{
Content, MessageId, ParentKind, Role, StopReason, TitleChange, TitleSource, ToolCallId,
@@ -120,7 +122,6 @@ use crate::harnesstools::session::{
};
use crate::os::fs::FdIdentity;
use crate::sync::BlockingPool;
use crate::utils::{env_nonempty, home_dir};
pub(crate) mod projection;
mod v2;
@@ -140,7 +141,7 @@ pub struct OpencodeSessions {
impl OpencodeSessions {
fn data_dir() -> PathBuf {
env_nonempty("XDG_DATA_HOME")
var_nonempty("XDG_DATA_HOME")
.map_or_else(|| home_dir().join(".local").join("share"), PathBuf::from)
.join("opencode")
}
@@ -183,7 +184,7 @@ impl OpencodeSessions {
if let Some(db) = &self.db {
return Ok(db.clone());
}
if let Some(env) = env_nonempty("OPENCODE_DB") {
if let Some(env) = var_nonempty("OPENCODE_DB") {
let path = PathBuf::from(env);
if path.as_os_str() == OsStr::new(":memory:") {
return Err(RuntimeError::Io(io::Error::new(
@@ -199,7 +200,7 @@ impl OpencodeSessions {
});
}
let data = Self::data_dir();
if env_nonempty("OPENCODE_DISABLE_CHANNEL_DB")
if var_nonempty("OPENCODE_DISABLE_CHANNEL_DB")
.is_some_and(|v| matches!(v.to_str(), Some("1" | "true")))
{
return Ok(data.join("opencode.db"));
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
use super::rehydrate::{RehydrateError, RehydrateSession};
use super::resume::{self, CwdRequirement, ResumeError, ResumePlan, ResumeTarget};
use super::{Harness, InstallHookError};
use crate::utils::{env_nonempty, home_dir};
use crate::dirs::home_dir;
use crate::env::var_nonempty;
pub mod rehydrate;
pub mod session;
@@ -11,7 +12,7 @@ pub mod session;
/// Pi's config directory: `PI_CODING_AGENT_DIR`, else `~/.pi/agent` (pi-mono
/// `packages/coding-agent/src/config.ts` `getAgentDir`).
fn agent_dir() -> PathBuf {
env_nonempty("PI_CODING_AGENT_DIR")
var_nonempty("PI_CODING_AGENT_DIR")
.map_or_else(|| home_dir().join(".pi").join("agent"), |dir| expand_tilde(&dir))
}
@@ -28,6 +28,7 @@ use time::format_description::well_known::Rfc3339;
use tokio::sync::watch;
use typed_builder::TypedBuilder;
use crate::env::var_nonempty;
use crate::fs::tree_watcher::{FileStat, TreeWatcher};
use crate::harnesstools::pi::{Pi, agent_dir, expand_tilde};
use crate::harnesstools::session::model::{
@@ -41,7 +42,6 @@ use crate::harnesstools::session::{
use crate::io::{FollowLines, Line, PathLineReader, PooledReadLines};
use crate::json::jsonl::JsonlExt;
use crate::sync::BlockingPool;
use crate::utils::env_nonempty;
/// How far into a file the header line is looked for, like pi's own bounded header scan
/// (session-manager.ts `MAX_SESSION_HEADER_SCAN_BYTES`).
@@ -66,7 +66,7 @@ impl PiSessions {
/// Where pi keeps sessions by default (see [`session_root`]).
pub(crate) fn default_root() -> PathBuf {
session_root(&agent_dir(), env_nonempty("PI_CODING_AGENT_SESSION_DIR").as_deref())
session_root(&agent_dir(), var_nonempty("PI_CODING_AGENT_SESSION_DIR").as_deref())
}
/// The file of session `id` under `root`. Pi names it `<timestamp>_<id>.jsonl`, so a file so
@@ -105,7 +105,7 @@ fn custom_session_dir(agent_dir: &Path, env: Option<&std::ffi::OsStr>) -> Option
/// `session-manager.ts` `getDefaultSessionDirPath`).
pub(crate) fn new_session_dir(cwd: &Path) -> PathBuf {
let agent_dir = agent_dir();
let env = env_nonempty("PI_CODING_AGENT_SESSION_DIR");
let env = var_nonempty("PI_CODING_AGENT_SESSION_DIR");
custom_session_dir(&agent_dir, env.as_deref()).unwrap_or_else(|| {
let cwd = cwd.to_string_lossy();
let cwd = cwd.strip_prefix(['/', '\\']).unwrap_or(&cwd).replace(['/', '\\', ':'], "-");
@@ -1156,7 +1156,7 @@ mod tests {
let expected = expected
.replace("<abs>", abs)
.replace("<agent>", &agent.path().to_string_lossy())
.replace("<home>", &crate::utils::home_dir().to_string_lossy());
.replace("<home>", &crate::dirs::home_dir().to_string_lossy());
assert_eq!(
session_root(agent.path(), env.map(std::ffi::OsStr::new)),
PathBuf::from(expected)
+2
View File
@@ -4,8 +4,10 @@
pub mod ansi;
#[cfg(feature = "db")]
pub mod db;
pub mod dirs;
pub mod docs;
pub mod encryption;
pub mod env;
pub mod filter;
pub mod fs;
pub mod futures;
+7 -4
View File
@@ -1,13 +1,16 @@
//! Unix-specific utilities.
use std::path::{Path, PathBuf};
use rustix::fs;
pub mod disk;
pub mod io;
pub mod process;
pub mod tty;
use std::path::{Path, PathBuf};
use rustix::fs;
mod socket;
pub use socket::socket_in_use;
/// Get the current UID.
#[must_use]
@@ -39,7 +42,7 @@ pub fn touch_file(path: &Path) -> std::io::Result<()> {
pub fn tmp_dir() -> PathBuf {
// TODO: We should perhaps use `std::env::temp_dir()` instead, but that would be a breaking
// change and could cause clients to fail to connect to an older running daemon.
crate::utils::env_nonempty("TMPDIR").map_or_else(|| "/tmp".into(), Into::into)
crate::env::var_nonempty("TMPDIR").map_or_else(|| "/tmp".into(), Into::into)
}
/// Error returned by [`create_secure_temp_dir`].
+32
View File
@@ -0,0 +1,32 @@
use std::path::Path;
use rustix::io::Errno;
use rustix::{fs, net};
/// Check whether a Unix socket is in use.
///
/// Unfortunately, on macOS and BSD, this can incorrectly return `false` if the socket is in use but
/// has a full backlog, as these systems return `ECONNREFUSED` in this case, which is
/// indistinguishable from a stale socket. Linux does not have this problem as it returns `EAGAIN`
/// instead. For Atuin's purposes (checking whether a socket is being actively used by an Atuin
/// daemon), this case is unlikely to happen in practice.
///
/// If `path` exists but is not a socket (e.g., is a regular file), this function will return either
/// `Ok(false)` or `Err(_)`. If you need to know whether `Ok(false)` was returned because a
/// non-socket file already exists at `path`, use [`std::fs::symlink_metadata`],
/// [`std::fs::Metadata::file_type`], and [`std::os::unix::fs::FileTypeExt::is_socket`].
///
/// # Errors
///
/// Returns an error if `connect()` returns anything other than 0 (success), `EAGAIN`, or
/// `ECONNREFUSED`.
pub fn socket_in_use(path: &Path) -> std::io::Result<bool> {
let fd = net::socket(net::AddressFamily::UNIX, net::SocketType::STREAM, None)?;
fs::fcntl_setfl(&fd, fs::fcntl_getfl(&fd)? | fs::OFlags::NONBLOCK)?;
match net::connect(&fd, &net::SocketAddrUnix::new(path)?) {
Ok(()) | Err(Errno::AGAIN) => Ok(true),
Err(Errno::CONNREFUSED) => Ok(false),
Err(e) => Err(e.into()),
}
}
+54
View File
@@ -10,6 +10,14 @@ pub use display_rich::{DisplayRichExt, RichDisplay};
pub trait PathExt {
/// Check whether the given path is a symlink, and a dangling one at that.
fn is_dangling_symlink(&self) -> bool;
/// Check whether two paths resolve to the same path.
///
/// This function checks whether the paths are the same after
/// [canonicalizing](Path::canonicalize) them into absolute paths with intermediate components
/// normalized and symlinks resolved. This requires both paths to exist; if they do not, this
/// function performs a naive comparison of the path contents.
fn is_same_path(&self, other: impl AsRef<Path>) -> bool;
}
impl<P: AsRef<Path>> PathExt for P {
@@ -17,6 +25,18 @@ impl<P: AsRef<Path>> PathExt for P {
let path: &Path = self.as_ref();
path.is_symlink() && !path.exists()
}
fn is_same_path(&self, other: impl AsRef<Path>) -> bool {
let (this, other) = (self.as_ref(), other.as_ref());
if this == other {
// Fast path: avoids calling `canonicalize`.
return true;
}
let (Ok(this), Ok(other)) = (this.canonicalize(), other.canonicalize()) else {
return false;
};
this == other
}
}
/// An owned path that is dependent on environment variables.
@@ -35,3 +55,37 @@ pub struct EnvDependentPathBuf {
/// For example, `/tmp/example.txt` even when `$TMPDIR` is set.
pub envless: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
fn is_same_path_compares_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir(&a).unwrap();
std::fs::create_dir(&b).unwrap();
assert!(a.is_same_path(&a));
assert!(a.is_same_path(dir.path().join("b").join("..").join("a")));
assert!(!a.is_same_path(&b));
// Paths that don't exist are only the same if they're equal.
assert!(dir.path().join("x").is_same_path(dir.path().join("x")));
assert!(!dir.path().join("x").is_same_path(dir.path().join("y")));
}
#[cfg(unix)]
#[rstest]
fn is_same_path_sees_through_symlinks() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let link = dir.path().join("link");
std::fs::create_dir(&target).unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(link.is_same_path(&target));
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ impl Shell {
#[must_use]
pub fn from_env() -> Self {
std::env::var("ATUIN_SHELL")
crate::env::var("ATUIN_SHELL")
.map_or(Self::Unknown, |shell| Self::from_string(&shell.trim().to_lowercase()))
}
@@ -100,7 +100,7 @@ impl Shell {
#[cfg(unix)]
if cfg!(target_os = "macos") {
let user = std::env::var_os("USER").ok_or(ShellLookupError::MissingEnv("USER"))?;
let user = crate::env::var_os("USER").ok_or(ShellLookupError::MissingEnv("USER"))?;
let path = PathBuf::from("/Local/Default/Users").join(user);
return shell_from_command(
"dscl".as_ref(),
+1 -139
View File
@@ -1,5 +1,4 @@
use std::env;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};
@@ -107,61 +106,10 @@ pub fn git_checkout_root(path: &str) -> Option<PathBuf> {
gitdir.parent().is_some().then_some(gitdir)
}
// TODO: more reliable, more tested
// I don't want to use ProjectDirs, it puts config in awkward places on
// mac. Data too. Seems to be more intended for GUI apps.
#[must_use]
pub fn home_dir() -> PathBuf {
dirs::home_dir().expect("could not determine home directory")
}
/// Read an environment variable that must be nonempty.
///
/// This function will never return an empty string: if the environment variable is set but empty,
/// [`None`] is returned.
#[must_use]
pub fn env_nonempty(name: &str) -> Option<OsString> {
std::env::var_os(name).filter(|value| !value.is_empty())
}
/// Read an environment variable that must be an absolute path.
///
/// This is usually done in the name of XDG-compliance which requires that paths given through
/// environment variables are absolute.
pub fn env_abspath(name: &str) -> Option<PathBuf> {
env_nonempty(name).map(PathBuf::from).filter(|s| s.is_absolute())
}
#[must_use]
pub fn config_dir() -> PathBuf {
let config_dir: PathBuf =
env_abspath("XDG_CONFIG_HOME").unwrap_or_else(|| home_dir().join(".config"));
config_dir.join("atuin")
}
#[must_use]
pub fn data_dir() -> PathBuf {
let data_dir: PathBuf =
env_abspath("XDG_DATA_HOME").unwrap_or_else(|| home_dir().join(".local").join("share"));
data_dir.join("atuin")
}
#[must_use]
pub fn logs_dir() -> PathBuf {
home_dir().join(".atuin").join("logs")
}
#[must_use]
pub fn dotfiles_cache_dir() -> PathBuf {
// In most cases, this will be ~/.local/share/atuin/dotfiles/cache
data_dir().join("dotfiles").join("cache")
}
#[must_use]
pub fn get_current_dir() -> String {
// Prefer PWD environment variable over cwd if available to better support symbolic links
match env::var("PWD") {
match crate::env::var("PWD") {
Ok(v) => v,
Err(_) => match env::current_dir() {
Ok(dir) => dir.display().to_string(),
@@ -230,92 +178,6 @@ mod tests {
use super::*;
#[cfg(not(windows))]
#[rstest]
fn test_dirs() {
// these tests need to be run sequentially to prevent race condition
test_config_dir_xdg();
test_config_dir_xdg_empty();
test_config_dir();
test_data_dir_xdg();
test_data_dir_xdg_empty();
test_data_dir();
}
#[cfg(not(windows))]
fn test_config_dir_xdg() {
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("HOME") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config") };
assert_eq!(config_dir(), PathBuf::from("/home/user/custom_config/atuin"));
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("XDG_CONFIG_HOME") };
}
/// An empty `XDG_CONFIG_HOME` has to be treated as unset: the alternative is a relative path.
#[cfg(not(windows))]
fn test_config_dir_xdg_empty() {
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("HOME", "/home/user") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("XDG_CONFIG_HOME", "") };
assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("XDG_CONFIG_HOME") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("HOME") };
}
#[cfg(not(windows))]
fn test_config_dir() {
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("HOME", "/home/user") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("XDG_CONFIG_HOME") };
assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("HOME") };
}
#[cfg(not(windows))]
fn test_data_dir_xdg() {
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("HOME") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("XDG_DATA_HOME", "/home/user/custom_data") };
assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("XDG_DATA_HOME") };
}
/// An empty `XDG_DATA_HOME` has to be treated as unset: the alternative is a relative path.
#[cfg(not(windows))]
fn test_data_dir_xdg_empty() {
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("HOME", "/home/user") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("XDG_DATA_HOME", "") };
assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("XDG_DATA_HOME") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("HOME") };
}
#[cfg(not(windows))]
fn test_data_dir() {
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::set_var("HOME", "/home/user") };
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("XDG_DATA_HOME") };
assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
// SAFETY: Runs in a single-threaded test context, so no other thread accesses the environment concurrently.
unsafe { env::remove_var("HOME") };
}
#[cfg(not(windows))]
#[rstest]
fn in_git_repo_regular() {
+1 -1
View File
@@ -59,7 +59,7 @@ uuid = { workspace = true }
listenfd = { workspace = true }
[dev-dependencies]
atuin-common = { workspace = true, features = ["os", "sqlite"] }
atuin-common = { workspace = true, features = ["test-utils"] }
divan = { workspace = true }
proptest = { workspace = true }
regex = { workspace = true }
+6 -2
View File
@@ -15,8 +15,12 @@ fn main() -> std::io::Result<()> {
let file_descriptors = protox::compile(proto_paths, proto_include_dirs).unwrap();
let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"))
.join("file_descriptor_set.bin");
#[expect(
clippy::disallowed_methods,
reason = "build scripts can't use atuin_common, and Cargo sets OUT_DIR for them"
)]
let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR not set");
let file_descriptor_path = PathBuf::from(out_dir).join("file_descriptor_set.bin");
fs::write(&file_descriptor_path, file_descriptors.encode_to_vec()).unwrap();
tonic_prost_build::configure()
+295 -39
View File
@@ -1,6 +1,8 @@
#[cfg(unix)]
use std::borrow::Cow;
use std::num::NonZeroU32;
#[cfg(unix)]
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use atuin_client::ai_session::{HarnessKind, HarnessSession, SearchTerms, SessionFilter};
use atuin_client::database::Context;
@@ -46,26 +48,138 @@ use crate::search::{
SearchContext as RpcSearchContext, SearchRequest, SearchResponse,
};
#[cfg(unix)]
#[derive(Debug, thiserror::Error)]
pub enum SocketPathError {
/// The socket is in use by another program.
///
/// This error can be returned when `ATUIN_HOME` is set. Atuin started storing the socket path
/// in the pidfile before `ATUIN_HOME` was introduced, so if there's no socket path in the
/// pidfile but the socket already exists, it's an indication that the socket is in use by
/// another program (potentially another Atuin daemon running with a different value of
/// `ATUIN_HOME`).
#[error("{}", Self::display_in_use(.path, *.user_defined))]
InUse {
/// The path to the socket.
path: PathBuf,
/// Whether the path came from `daemon.socket_path` in config.toml.
user_defined: bool,
},
}
#[cfg(unix)]
impl SocketPathError {
/// Helper for displaying [`Self::InUse`].
fn display_in_use(path: &Path, user_defined: bool) -> impl std::fmt::Display + use<'_> {
std::fmt::from_fn(move |f| {
writeln!(f, "daemon socket '{}' is in use by another program", path.display())?;
let config_path = atuin_common::dirs::config_path("config.toml");
if user_defined {
write!(
f,
"please ensure `daemon.socket_path` has a unique value in {}",
config_path.display(),
)
} else {
write!(
f,
"please set an explicit `daemon.socket_path` in {}",
config_path.display()
)
}
})
}
}
/// The path to the daemon's socket.
///
/// If the daemon is running and has recorded its socket path in the pidfile, this function returns
/// that. Otherwise, this function returns [`settings.daemon.existing_socket_path()`][0].
/// that. Otherwise:
///
/// As an exception, if [`systemd_socket`][1] is true, the pidfile isn't consulted, as the socket
/// * If `ATUIN_HOME` is unset (the default), [`settings.daemon.existing_socket_path()`][0] is
/// returned, which will check for existing sockets in legacy locations.
///
/// * If `ATUIN_HOME` is set, [`settings.daemon.preferred_socket_path()`][1] is returned, which does
/// not check for legacy sockets, as Atuin stopped using them before `ATUIN_HOME` was introduced.
/// In addition, an error is returned if that socket already exists: Atuin started storing the
/// socket path in the pidfile before `ATUIN_HOME`, so if an existing socket isn't in our pidfile,
/// it almost certainly isn't ours. This behavior is intended to reduce the chance of the client
/// connecting to the wrong daemon.
///
/// As an exception, if [`systemd_socket`][2] is true, the pidfile isn't consulted, as the socket
/// path comes from systemd directly through a file descriptor.
///
/// [0]: atuin_client::settings::Daemon::existing_socket_path
/// [1]: atuin_client::settings::Daemon::systemd_socket
/// [1]: atuin_client::settings::Daemon::preferred_socket_path
/// [2]: atuin_client::settings::Daemon::systemd_socket
#[cfg(unix)]
#[must_use]
pub fn socket_path(settings: &atuin_client::settings::Settings) -> PathBuf {
(!settings.daemon.systemd_socket)
.then(|| {
crate::pidfile::PidfileInfo::read(settings.daemon.pidfile_path.as_ref())
.and_then(|info| info.socket_path)
})
.flatten()
.unwrap_or_else(|| settings.daemon.existing_socket_path().into_owned())
pub fn socket_path(settings: &Settings) -> Result<PathBuf, SocketPathError> {
use crate::pidfile::{self, PidfileInfo};
let existing_socket_path = || settings.daemon.existing_socket_path().into_owned();
if settings.daemon.systemd_socket {
return Ok(existing_socket_path());
}
let pidfile_path: &Path = settings.daemon.pidfile_path.as_ref();
let socket_path = PidfileInfo::read(pidfile_path).and_then(|info| info.socket_path);
if !atuin_common::dirs::atuin_home_is_set() {
return Ok(socket_path.unwrap_or_else(existing_socket_path));
}
// If `ATUIN_HOME` is set, require the pidfile to be live to avoid connecting to the wrong
// daemon. This should potentially be done even when `ATUIN_HOME` isn't set, but that would be a
// larger and possibly riskier change. For now, scope the check to when `ATUIN_HOME` is set,
// which is when we would most expect there to be multiple daemons running on the user's system.
if let Some(path) = socket_path.filter(|_| pidfile::is_live(pidfile_path)) {
return Ok(path);
}
// If there's no socket path in the pidfile (or the pidfile was missing/stale) and `ATUIN_HOME`
// is set, don't look for or connect to existing sockets. Atuin switched to storing the socket
// path in the pidfile before `ATUIN_HOME` was introduced, so an existing socket is almost
// certainly that of another program, potentially another daemon running with a different value
// of `ATUIN_HOME`, which is conceptually a separate profile that we should not connect to.
let path = settings.daemon.preferred_socket_path().into_owned();
// Avoid connecting to an existing, in-use socket. We match on `Ok(true)` rather than
// `Ok(true) | Err(_)` because an error doesn't necessarily mean the socket is in use (e.g.,
// `EACCES`), and many such errors will be encountered again later in the pipeline and handled
// better there.
if path.exists() && matches!(atuin_common::os::unix::socket_in_use(&path), Ok(true)) {
return Err(SocketPathError::InUse {
path,
user_defined: settings.daemon.socket_path.is_some(),
});
}
Ok(path)
}
/// The paths that should be checked when looking for existing daemon sockets.
///
/// For the same reasons explained in [`socket_path`], when `ATUIN_HOME` is set, this function will
/// only yield the [preferred socket path][0] instead of also including the legacy paths.
///
/// [0]: atuin_client::settings::Daemon::preferred_socket_path
#[cfg(unix)]
pub fn potential_socket_paths(
settings: &Settings,
) -> impl Iterator<Item = Cow<'_, Path>> + use<'_> {
if atuin_common::dirs::atuin_home_is_set() {
itertools::Either::Left(std::iter::once(settings.daemon.preferred_socket_path()))
} else {
itertools::Either::Right(settings.daemon.potential_socket_paths())
}
}
#[derive(Debug, thiserror::Error)]
pub enum FromSettingsError {
#[cfg(unix)]
#[error(transparent)]
SocketPath(#[from] SocketPathError),
#[error(transparent)]
CreateClient(#[from] eyre::Report),
}
pub struct HistoryClient {
@@ -151,14 +265,12 @@ impl HistoryClient {
Ok(HistoryClient { client })
}
#[cfg(unix)]
pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(socket_path(settings)).await
}
#[cfg(not(unix))]
pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(settings.daemon.tcp_port).await
pub async fn from_settings(settings: &Settings) -> Result<Self, FromSettingsError> {
#[cfg(unix)]
let address = socket_path(settings)?;
#[cfg(not(unix))]
let address = settings.daemon.tcp_port;
Self::new(address).await.map_err(Into::into)
}
pub async fn start_history(&mut self, h: History) -> Result<StartHistoryReply> {
@@ -372,14 +484,12 @@ impl SearchClient {
Ok(SearchClient { client })
}
#[cfg(unix)]
pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(socket_path(settings)).await
}
#[cfg(not(unix))]
pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(settings.daemon.tcp_port).await
pub async fn from_settings(settings: &Settings) -> Result<Self, FromSettingsError> {
#[cfg(unix)]
let address = socket_path(settings)?;
#[cfg(not(unix))]
let address = settings.daemon.tcp_port;
Self::new(address).await.map_err(Into::into)
}
#[instrument(
@@ -528,14 +638,12 @@ impl AiClient {
})
}
#[cfg(unix)]
pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(socket_path(settings)).await
}
#[cfg(not(unix))]
pub async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(settings.daemon.tcp_port).await
pub async fn from_settings(settings: &Settings) -> Result<Self, FromSettingsError> {
#[cfg(unix)]
let address = socket_path(settings)?;
#[cfg(not(unix))]
let address = settings.daemon.tcp_port;
Self::new(address).await.map_err(Into::into)
}
/// Wait while the daemon is still rebuilding AI sessions after starting: until then every
@@ -666,6 +774,7 @@ impl AiClient {
mod tests {
use std::path::Path;
use atuin_common::env::MockEnv;
use rstest::rstest;
use super::*;
@@ -693,13 +802,15 @@ mod tests {
#[case] expected: &str,
) {
let dir = tempfile::tempdir().unwrap();
let _env = env(dir.path(), false);
let pidfile = dir.path().join("atuin-daemon.pid");
let old = settings(&pidfile, &dir.path().join("old.sock"), daemon_systemd_socket);
drop(PidfileGuard::acquire(&old.daemon).unwrap());
let new = settings(&pidfile, &dir.path().join("new.sock"), client_systemd_socket);
assert_eq!(socket_path(&new), dir.path().join(expected));
assert_eq!(socket_path(&new).unwrap(), dir.path().join(expected));
}
#[rstest]
@@ -710,7 +821,152 @@ mod tests {
&dir.path().join("a.sock"),
systemd_socket,
);
assert_eq!(socket_path(&settings), dir.path().join("a.sock"));
assert_eq!(socket_path(&settings).unwrap(), dir.path().join("a.sock"));
}
/// A mock environment with `HOME` and `TMPDIR` in `dir`, and `ATUIN_HOME` too if
/// `atuin_home_set`, so that neither the settings nor the default socket path involve the
/// real home or `/tmp`.
fn env(dir: &Path, atuin_home_set: bool) -> MockEnv {
let env = MockEnv::install();
env.set("HOME", dir.join("home"));
env.set("TMPDIR", dir.join("tmp"));
if atuin_home_set {
env.set("ATUIN_HOME", dir.join("profile"));
}
env
}
/// Listen on a Unix socket at `path`, as a running daemon does, until the listener is
/// dropped. Dropping it leaves the socket file behind with nothing listening, as a daemon that
/// was killed does.
fn listen(path: &Path) -> std::os::unix::net::UnixListener {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::os::unix::net::UnixListener::bind(path).unwrap()
}
/// The default socket path for `dir` as [`env`] sets it up, or the configured one.
fn preferred_path(dir: &Path, configured: bool) -> PathBuf {
if configured {
dir.join("configured.sock")
} else {
dir.join("tmp")
.join(format!("atuin-{}", atuin_common::os::unix::uid()))
.join("atuin.sock")
}
}
/// Settings with the socket at [`preferred_path`], and a pidfile in `dir`.
fn settings_in(dir: &Path, configured: bool) -> Settings {
let mut settings =
settings(&dir.join("atuin-daemon.pid"), &dir.join("configured.sock"), false);
if !configured {
settings.daemon.socket_path = None;
}
assert_eq!(settings.daemon.preferred_socket_path(), preferred_path(dir, configured));
settings
}
/// With `ATUIN_HOME` set, a live socket that the pidfile doesn't name belongs to something
/// else, such as a daemon for another `ATUIN_HOME`, so the client refuses it rather than
/// connect. Without `ATUIN_HOME`, an existing socket is connected to as before.
#[rstest]
fn an_unrecorded_live_socket_is_refused_only_with_atuin_home(
#[values(false, true)] atuin_home_set: bool,
#[values(false, true)] configured: bool,
) {
let dir = tempfile::tempdir().unwrap();
let _env = env(dir.path(), atuin_home_set);
let settings = settings_in(dir.path(), configured);
let path = preferred_path(dir.path(), configured);
// Nothing's there yet, so it's free for this profile's daemon. (Without `ATUIN_HOME`, the
// client would also fall back to sockets elsewhere, like the real `/tmp/atuin-$UID`.)
if atuin_home_set {
assert_eq!(socket_path(&settings).unwrap(), path);
}
let _listener = listen(&path);
let result = socket_path(&settings);
if !atuin_home_set {
assert_eq!(result.unwrap(), path);
return;
}
let err = result.unwrap_err();
let message = err.to_string();
let SocketPathError::InUse {
path: in_use,
user_defined,
} = err;
assert_eq!(in_use, path);
assert_eq!(user_defined, configured);
assert!(message.contains(&path.display().to_string()), "{message}");
let hint = if configured {
"has a unique value"
} else {
"set an explicit"
};
assert!(message.contains(hint), "{message}");
}
/// With `ATUIN_HOME` set, a socket left behind with nothing listening, as by a daemon that was
/// killed, is free: the client uses it, and autostart replaces it.
#[rstest]
fn with_atuin_home_a_stale_socket_is_free(#[values(false, true)] configured: bool) {
let dir = tempfile::tempdir().unwrap();
let _env = env(dir.path(), true);
let settings = settings_in(dir.path(), configured);
let path = preferred_path(dir.path(), configured);
drop(listen(&path));
assert!(path.exists(), "the stale socket should be left behind");
assert_eq!(socket_path(&settings).unwrap(), path);
}
/// With `ATUIN_HOME` set, the socket the pidfile names is used while its daemon is running,
/// whatever else exists.
#[rstest]
fn with_atuin_home_the_recorded_socket_is_used() {
let dir = tempfile::tempdir().unwrap();
let _env = env(dir.path(), true);
let pidfile = dir.path().join("atuin-daemon.pid");
let daemon = settings(&pidfile, &dir.path().join("daemon.sock"), false);
let _guard = PidfileGuard::acquire(&daemon.daemon).unwrap();
let client = settings(&pidfile, &dir.path().join("other.sock"), false);
let _listener = listen(&dir.path().join("other.sock"));
assert_eq!(socket_path(&client).unwrap(), dir.path().join("daemon.sock"));
}
/// With `ATUIN_HOME` set, a socket recorded by a daemon that has since exited isn't trusted:
/// another profile's daemon may be using that path now. The client goes by its own settings,
/// and refuses the path if something is live there.
#[rstest]
fn with_atuin_home_a_stale_pidfile_is_not_trusted(#[values(false, true)] live: bool) {
let dir = tempfile::tempdir().unwrap();
let _env = env(dir.path(), true);
let pidfile = dir.path().join("atuin-daemon.pid");
// A daemon recorded `daemon.sock`, then exited, leaving its pidfile behind. Its path is
// now live, as if another profile's daemon had taken it.
let daemon = settings(&pidfile, &dir.path().join("daemon.sock"), false);
drop(PidfileGuard::acquire(&daemon.daemon).unwrap());
let _taken = listen(&dir.path().join("daemon.sock"));
let client = settings_in(dir.path(), true);
let path = preferred_path(dir.path(), true);
let _listener = live.then(|| listen(&path));
match socket_path(&client) {
Ok(found) => {
assert!(!live, "a live socket at {} was accepted", path.display());
assert_eq!(found, path);
}
Err(SocketPathError::InUse { path: in_use, .. }) => {
assert!(live, "a free socket path was refused");
assert_eq!(in_use, path);
}
}
}
}
+3 -3
View File
@@ -274,7 +274,7 @@ pub async fn boot(
let output_capture = match settings.output.limits() {
Some(limits) => {
OutputCaptureEngine::open(Settings::command_capture_dir(), limits.max_disk_usage).await
OutputCaptureEngine::open(settings.command_capture_dir(), limits.max_disk_usage).await
}
None => OutputCaptureEngine::nop(),
};
@@ -293,9 +293,9 @@ pub async fn boot(
let handle = daemon.handle();
let host_id = Settings::host_id().await?;
let host_id = settings.host_id().await?;
let ai_session_db_path = atuin_client::ai_session::sidecar_path();
let ai_session_db_path = atuin_client::ai_session::sidecar_path(&settings);
let ai_session_db = match AiSessionDatabase::open(&ai_session_db_path).await {
Ok(db) => Some(db),
Err(err) => {
@@ -37,7 +37,7 @@ impl ActiveOctavoClient {
handle: DaemonHandle,
outputs: Arc<OutputStore>,
) -> Result<Self, OpenActiveOctavoClientError> {
let queue = UploadQueue::open(Settings::octavo_queue_path()).await?;
let queue = UploadQueue::open(settings.octavo_queue_path()).await?;
let hub = HubClient::new(settings)?;
let wake = Arc::new(Notify::new());
let engine =
@@ -191,7 +191,7 @@ impl DirectUploader {
/// That needs the token's user, known once the daemon has reached the hub since the login;
/// until then a detached task asks the hub first.
async fn queue_for(self: &Arc<Self>, settings: Settings, token: HubToken, work: Queued) {
match self.hub.user_of(&token).await {
match self.hub.user_of(&token, &settings).await {
Ok(Some(user)) => self.enqueue(&user, work).await,
Ok(None) => {
tokio::spawn(Arc::clone(self).learn_user_and_queue(settings, token, work));
+1 -1
View File
@@ -24,7 +24,7 @@ impl Octavo {
) -> Self {
// Once Octavo has been on here, deletions keep reaching it with uploads off, so what the
// user deletes locally leaves Octavo too.
if !settings.octavo.enabled && !Settings::octavo_queue_path().exists() {
if !settings.octavo.enabled && !settings.octavo_queue_path().exists() {
return Self::nop();
}
+15
View File
@@ -304,6 +304,21 @@ pub fn try_read_pid(pidfile_path: &Path) -> Option<u32> {
.ok()
}
/// Check whether a pidfile is live; that is, whether it is associated with an actively running
/// daemon.
#[must_use]
pub fn is_live(pidfile_path: &Path) -> bool {
LockOptions {
create: false,
// The daemon always acquires an exclusive lock on the pidfile. Here, try to obtain a shared
// lock to slightly reduce the chance of false positives in case some other process is
// holding a lock on the file.
mode: LockMode::Shared,
}
.try_open(pidfile_path)
.is_err_and(|e| matches!(e, LockError::WouldBlock))
}
#[cfg(test)]
mod tests {
use std::fs::OpenOptions;
@@ -3788,22 +3788,18 @@ mod pipeline_tests {
assert_eq!(row.title.as_deref(), Some("Old work"));
}
/// Serializes the tests that point `CODEX_HOME` somewhere: the variable is process-wide, and
/// a rehydrate that found it unset would write to the real `~/.codex`.
static CODEX_HOME: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Write `session` out as Codex would find it, under a temporary `CODEX_HOME`.
async fn rehydrate_codex(
session: &atuin_common::harnesstools::rehydrate::RehydrateSession,
home: &std::path::Path,
) -> std::path::PathBuf {
let _env = CODEX_HOME.lock().await;
// SAFETY: no other thread of these tests reads or writes the environment meanwhile.
unsafe { std::env::set_var("CODEX_HOME", home) };
// The mock is thread-local. `rehydrate` reads `CODEX_HOME` before it first yields.
let env = atuin_common::env::MockEnv::install();
env.set("CODEX_HOME", home);
let written = atuin_common::harnesstools::codex::rehydrate::rehydrate(session).await;
// SAFETY: as above.
unsafe { std::env::remove_var("CODEX_HOME") };
drop(env);
let path = written.unwrap();
// Make sure we wrote to `CODEX_HOME` and not the real `~/.codex`.
assert!(path.starts_with(home), "{} is outside the test's CODEX_HOME", path.display());
path
}
@@ -118,7 +118,7 @@ impl Worker {
ai_session_db: Option<AiSessionDatabase>,
ai_session_recovery: Recovery,
) -> Result<Self, StartError> {
let host_id = Settings::host_id().await.map_err(StartError::HostId)?;
let host_id = handle.settings().await.host_id().await.map_err(StartError::HostId)?;
let encryption_key = handle.encryption_key();
// TODO(markovejnovic): We should definitely not be creating new stores, but this is pending
@@ -230,7 +230,7 @@ impl Worker {
self.index_downloaded_records(&downloaded_records).await;
// Store sync time
if let Err(e) = Settings::save_sync_time().await {
if let Err(e) = settings.save_sync_time().await {
tracing::error!("failed to save sync time: {e}");
}
+1 -1
View File
@@ -98,7 +98,7 @@ proptest! {
// `History::new` falls back to `ATUIN_HISTORY_AUTHOR`/`ATUIN_HISTORY_INTENT` when the
// wire field is unset, so a developer shell exporting either must not fail this test.
let expected_intent = normalize_optional_string(Some(req.intent.clone()))
.or_else(|| normalize_optional_string(std::env::var("ATUIN_HISTORY_INTENT").ok()));
.or_else(|| normalize_optional_string(atuin_common::env::var("ATUIN_HISTORY_INTENT").ok()));
prop_assert_eq!(&row.intent, &expected_intent);
prop_assert_eq!(&row.shell, &normalize_optional_string(Some(req.shell.clone())));
let expected_author = normalize_optional_string(Some(req.author.clone()))
+5
View File
@@ -134,6 +134,11 @@ impl TestEnvBuilder {
.unwrap()
.set_override("daemon.socket_path", socket_path.to_str().unwrap())
.unwrap()
.set_override("meta.db_path", tmp.path().join("meta.db").to_str().unwrap())
.unwrap()
// Prevent meta store from migrating files from the real data dir.
.set_override("data_dir", tmp.path().to_str().unwrap())
.unwrap()
// Unroutable on purpose: the capability warm-up must fail fast, not dial the internet.
.set_override("sync_address", "http://127.0.0.1:1")
.unwrap()
+4 -3
View File
@@ -12,7 +12,7 @@ mod unix {
use atuin_client::database::{Context, Sqlite};
use atuin_client::history::{History, HistoryId};
use atuin_client::record::sqlite_store::SqliteStore;
use atuin_client::settings::{FilterMode, Settings, init_meta_config_for_testing};
use atuin_client::settings::{FilterMode, Settings};
use atuin_common::filter::OrFilter;
use atuin_daemon::client::{SearchClient, SearchParams};
use atuin_daemon::components::SearchComponent;
@@ -59,8 +59,6 @@ mod unix {
let socket_path = tmp.path().join("test.sock");
let meta_path = tmp.path().join("meta.db");
init_meta_config_for_testing(meta_path.to_str().unwrap(), 5.0);
let settings: Settings = Settings::builder()
.expect("could not build settings builder")
.set_override("db_path", db_path.to_str().unwrap())
@@ -73,6 +71,9 @@ mod unix {
.expect("failed to set socket_path")
.set_override("meta.db_path", meta_path.to_str().unwrap())
.expect("failed to set meta.db_path")
// Prevent meta store from migrating files from the real data dir.
.set_override("data_dir", tmp.path().to_str().unwrap())
.expect("failed to set data_dir")
.build()
.expect("could not build settings")
.try_deserialize()
+2 -2
View File
@@ -22,7 +22,7 @@ impl<T: Into<String>> CmdHost<T> {
impl CmdHost<String> {
pub fn probe_current() -> Self {
std::env::var("ATUIN_HOST_NAME")
atuin_common::env::var("ATUIN_HOST_NAME")
.ok()
.or_else(|| whoami::hostname().ok())
.map(Self)
@@ -60,7 +60,7 @@ impl<T: Into<String>> CmdUser<T> {
impl CmdUser<String> {
pub fn probe_current() -> Self {
std::env::var("ATUIN_HOST_USER")
atuin_common::env::var("ATUIN_HOST_USER")
.ok()
.or_else(|| whoami::username().ok())
.map(Self)
+2 -2
View File
@@ -41,7 +41,7 @@ async fn cached_vars(path: PathBuf, store: &VarStore) -> String {
#[must_use]
pub async fn alias_config(store: &AliasStore) -> String {
// First try to read the cached config
let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.bash");
let aliases = atuin_common::dirs::dotfiles_cache_dir().join("aliases.bash");
if aliases.exists() {
return cached_aliases(aliases, store).await;
@@ -57,7 +57,7 @@ pub async fn alias_config(store: &AliasStore) -> String {
#[must_use]
pub async fn var_config(store: &VarStore) -> String {
// First try to read the cached config
let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.bash");
let vars = atuin_common::dirs::dotfiles_cache_dir().join("vars.bash");
if vars.exists() {
return cached_vars(vars, store).await;
+2 -2
View File
@@ -42,7 +42,7 @@ async fn cached_vars(path: PathBuf, store: &VarStore) -> String {
#[must_use]
pub async fn alias_config(store: &AliasStore) -> String {
// First try to read the cached config
let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.fish");
let aliases = atuin_common::dirs::dotfiles_cache_dir().join("aliases.fish");
if aliases.exists() {
return cached_aliases(aliases, store).await;
@@ -58,7 +58,7 @@ pub async fn alias_config(store: &AliasStore) -> String {
#[must_use]
pub async fn var_config(store: &VarStore) -> String {
// First try to read the cached config
let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.fish");
let vars = atuin_common::dirs::dotfiles_cache_dir().join("vars.fish");
if vars.exists() {
return cached_vars(vars, store).await;
@@ -42,7 +42,7 @@ async fn cached_vars(path: PathBuf, store: &VarStore) -> String {
#[must_use]
pub async fn alias_config(store: &AliasStore) -> String {
// First try to read the cached config
let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.ps1");
let aliases = atuin_common::dirs::dotfiles_cache_dir().join("aliases.ps1");
if aliases.exists() {
return cached_aliases(aliases, store).await;
@@ -58,7 +58,7 @@ pub async fn alias_config(store: &AliasStore) -> String {
#[must_use]
pub async fn var_config(store: &VarStore) -> String {
// First try to read the cached config
let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.ps1");
let vars = atuin_common::dirs::dotfiles_cache_dir().join("vars.ps1");
if vars.exists() {
return cached_vars(vars, store).await;
@@ -154,7 +154,7 @@ mod tests {
);
}
#[test]
#[rstest]
fn invoke_expression() {
assert_eq!(
secure_command("echo 'foo'"),
+2 -2
View File
@@ -41,7 +41,7 @@ async fn cached_vars(path: PathBuf, store: &VarStore) -> String {
#[must_use]
pub async fn alias_config(store: &AliasStore) -> String {
// First try to read the cached config
let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.xsh");
let aliases = atuin_common::dirs::dotfiles_cache_dir().join("aliases.xsh");
if aliases.exists() {
return cached_aliases(aliases, store).await;
@@ -57,7 +57,7 @@ pub async fn alias_config(store: &AliasStore) -> String {
#[must_use]
pub async fn var_config(store: &VarStore) -> String {
// First try to read the cached config
let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.xsh");
let vars = atuin_common::dirs::dotfiles_cache_dir().join("vars.xsh");
if vars.exists() {
return cached_vars(vars, store).await;
+2 -2
View File
@@ -41,7 +41,7 @@ async fn cached_vars(path: PathBuf, store: &VarStore) -> String {
#[must_use]
pub async fn alias_config(store: &AliasStore) -> String {
// First try to read the cached config
let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.zsh");
let aliases = atuin_common::dirs::dotfiles_cache_dir().join("aliases.zsh");
if aliases.exists() {
return cached_aliases(aliases, store).await;
@@ -57,7 +57,7 @@ pub async fn alias_config(store: &AliasStore) -> String {
#[must_use]
pub async fn var_config(store: &VarStore) -> String {
// First try to read the cached config
let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.zsh");
let vars = atuin_common::dirs::dotfiles_cache_dir().join("vars.zsh");
if vars.exists() {
return cached_vars(vars, store).await;
+2 -2
View File
@@ -183,7 +183,7 @@ impl AliasStore {
}
pub async fn build(&self) -> Result<()> {
let dir = atuin_common::utils::dotfiles_cache_dir();
let dir = atuin_common::dirs::dotfiles_cache_dir();
tokio::fs::create_dir_all(dir.clone()).await?;
let aliases = self.aliases().await?;
@@ -334,7 +334,7 @@ impl AliasStore {
#[cfg(test)]
pub(crate) fn test_local_timeout() -> std::time::Duration {
let secs = std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
let secs = atuin_common::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
.ok()
.and_then(|x| x.parse::<f64>().ok())
// this hardcoded value should be replaced by a simple way to get the
+1 -1
View File
@@ -231,7 +231,7 @@ impl VarStore {
}
pub async fn build(&self) -> Result<()> {
let dir = atuin_common::utils::dotfiles_cache_dir();
let dir = atuin_common::dirs::dotfiles_cache_dir();
tokio::fs::create_dir_all(dir.clone()).await?;
let env = self.vars().await?;
+10 -5
View File
@@ -144,11 +144,12 @@ impl HubClient {
timeout: settings.network_timeout,
};
let user_id = match Self::known_user(&token).await? {
let user_id = match Self::known_user(&token, settings).await? {
Some(id) => id,
None => {
let id = caller.who_am_i().await?;
Settings::meta_store()
settings
.meta_store()
.await
.map_err(HubCallError::Meta)?
.save_hub_user_id(&token, id.as_str())
@@ -166,12 +167,16 @@ impl HubClient {
pub async fn user_of(
&self,
HubToken(token): &HubToken,
settings: &Settings,
) -> Result<Option<UserId>, HubCallError> {
Self::known_user(token).await
Self::known_user(token, settings).await
}
async fn known_user(token: &SecretString) -> Result<Option<UserId>, HubCallError> {
let meta = Settings::meta_store().await.map_err(HubCallError::Meta)?;
async fn known_user(
token: &SecretString,
settings: &Settings,
) -> Result<Option<UserId>, HubCallError> {
let meta = settings.meta_store().await.map_err(HubCallError::Meta)?;
let id = meta.hub_user_id(token).await.map_err(HubCallError::Meta)?;
Ok(id.map(UserId))
}
+3 -3
View File
@@ -112,13 +112,13 @@ fn detect_shell(cli_shell: Option<Shell>) -> Result<Shell, String> {
return Ok(shell);
}
if let Ok(shell) = std::env::var("ATUIN_SHELL")
if let Ok(shell) = atuin_common::env::var("ATUIN_SHELL")
&& let Some(shell) = shell_from_name(&shell)
{
return Ok(shell);
}
if let Ok(shell) = std::env::var("SHELL")
if let Ok(shell) = atuin_common::env::var("SHELL")
&& let Some(shell) = shell_from_name(&shell)
{
return Ok(shell);
@@ -142,7 +142,7 @@ fn shell_from_name(name: &str) -> Option<Shell> {
}
fn env_flag(name: &str) -> bool {
std::env::var(name).is_ok_and(|value| {
atuin_common::env::var(name).is_ok_and(|value| {
matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
})
}
+4 -3
View File
@@ -28,6 +28,7 @@ use std::sync::{Arc, OnceLock};
use atuin_client::history::History;
use atuin_client::settings::Search as SearchSettings;
use atuin_common::env;
use atuin_common::filter::OrFilter;
use atuin_common::path::DisplayRichExt;
use atuin_daemon::search::{IndexFilterMode, SearchIndex};
@@ -83,13 +84,13 @@ impl fmt::Display for Case {
}
fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
fn scales() -> &'static Vec<usize> {
static SCALES: OnceLock<Vec<usize>> = OnceLock::new();
SCALES.get_or_init(|| {
let raw = std::env::var("BENCH_SCALES").unwrap_or_else(|_| "10000,100000,1000000".into());
let raw = env::var("BENCH_SCALES").unwrap_or_else(|_| "10000,100000,1000000".into());
let mut scales = Vec::new();
for part in raw.split(',') {
match part.trim().parse::<usize>() {
@@ -127,7 +128,7 @@ fn commands() -> &'static Vec<String> {
static COMMANDS: OnceLock<Vec<String>> = OnceLock::new();
COMMANDS.get_or_init(|| {
let max_scale = *scales().iter().max().expect("validated non-empty");
if let Ok(path) = std::env::var("BENCH_DATA") {
if let Ok(path) = env::var("BENCH_DATA") {
let raw = std::fs::read(&path).expect("failed to read BENCH_DATA");
let mut lines: Vec<String> = raw
.split(|&b| b == 0x1E)
+4 -13
View File
@@ -1,5 +1,4 @@
use std::io::prelude::*;
use std::path::PathBuf;
use atuin_common::units::ByteSize;
use config::{Config, Environment, File as ConfigFile, FileFormat};
@@ -53,16 +52,7 @@ pub struct Settings {
impl Settings {
pub fn new() -> Result<Self> {
let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
PathBuf::from(p)
} else {
let mut config_file = PathBuf::new();
let config_dir = atuin_common::utils::config_dir();
config_file.push(config_dir);
config_file
};
config_file.push("server.toml");
let config_file = atuin_common::dirs::config_path("server.toml");
// create the config file if it does not exist
let mut config_builder = Config::builder()
@@ -103,6 +93,7 @@ pub fn example_config() -> &'static str {
mod tests {
use atuin_common::units::ByteSize;
use config::{Config, File as ConfigFile, FileFormat};
use rstest::rstest;
use super::Settings;
@@ -121,13 +112,13 @@ mod tests {
.unwrap()
}
#[test]
#[rstest]
fn max_record_size_accepts_a_human_string() {
let settings = settings_with_max_record_size("\"500MB\"");
assert_eq!(settings.max_record_size, ByteSize::mb(500));
}
#[test]
#[rstest]
fn max_record_size_accepts_a_bare_integer() {
let settings = settings_with_max_record_size("1048576");
assert_eq!(settings.max_record_size, ByteSize::mib(1));
+1 -2
View File
@@ -1,4 +1,3 @@
use std::env;
use std::time::Duration;
use atuin_client::api_client;
@@ -26,7 +25,7 @@ pub async fn start_server(path: &str) -> (url::Url, oneshot::Sender<()>, JoinHan
.with(EnvFilter::new("atuin_server=debug,atuin_client=debug,info"))
.into();
let db_uri = env::var("ATUIN_DB_URI")
let db_uri = atuin_common::env::var("ATUIN_DB_URI")
.unwrap_or_else(|_| "postgres://atuin:pass@localhost:5432/atuin".to_owned());
let server_settings = ServerSettings {
+2 -2
View File
@@ -1,4 +1,4 @@
use std::env::{self, temp_dir};
use std::env::temp_dir;
use atuin_common::db::DbUrl;
use atuin_common::utils::{crypto_random_string, uuid_v7};
@@ -33,7 +33,7 @@ fn get_settings(env_uri: Option<String>) -> eyre::Result<DbSettings> {
}
async fn create_test_db() -> eyre::Result<DbSettings> {
let var = env::var("ATUIN_TEST_DB_URI").ok();
let var = atuin_common::env::var("ATUIN_TEST_DB_URI").ok();
let settings = get_settings(var)?;
match &settings.db_uri {
+1
View File
@@ -166,6 +166,7 @@ tree-sitter-powershell = { workspace = true }
windows-sys = { workspace = true }
[dev-dependencies]
atuin-common = { workspace = true, features = ["test-utils"] }
# An in-process sync server for the AI session e2e tests (tests/e2e_ai_sessions.rs).
atuin-server = { workspace = true }
divan = { workspace = true }
+1 -1
View File
@@ -239,7 +239,7 @@ impl Cmd {
#[allow(clippy::future_not_send)]
async fn run_async(self, mut settings: Settings) -> Result<()> {
tracing::trace!(command = ?self, "client command");
let mut theme_manager = theme::ThemeManager::new(settings.theme.debug, None);
let mut theme_manager = theme::ThemeManager::from_settings(&settings.theme);
// Skip initializing any databases for history
// This is a pretty hot path, as it runs before and after every single command the user
+3 -3
View File
@@ -4,7 +4,7 @@ use std::str::FromStr;
use atuin_client::record::sqlite_store::SqliteStore;
use atuin_client::settings::Settings;
use atuin_common::utils::env_nonempty;
use atuin_common::env::var_nonempty;
use clap::{Args, Subcommand};
use eyre::{Context, Result};
use secrecy::SecretString;
@@ -46,7 +46,7 @@ impl PasswordArg {
/// [`Self::Stdin`] reads `stdin` to its end and drops the trailing newline.
pub fn resolve(arg: Option<&Self>, mut stdin: impl Read) -> Result<Option<SecretString>> {
let Some(arg) = arg else {
return Ok(env_nonempty(PASSWORD_ENV)
return Ok(var_nonempty(PASSWORD_ENV)
.and_then(|password| password.into_string().ok())
.map(SecretString::from));
};
@@ -95,7 +95,7 @@ impl Cmd {
match self.command {
Commands::Login(l) => l.run(&settings, &store).await,
Commands::Register(r) => r.run(&settings, &store).await,
Commands::Logout => logout::run().await,
Commands::Logout => logout::run(&settings).await,
Commands::Delete(d) => d.run(&settings).await,
Commands::ChangePassword(c) => c.run(&settings).await,
Commands::Link => link::run(&settings).await,
@@ -45,7 +45,7 @@ impl Cmd {
}
// Clean up sessions from meta store
let meta = Settings::meta_store().await?;
let meta = settings.meta_store().await?;
meta.delete_session().await?;
meta.delete_hub_session().await?;
@@ -4,7 +4,7 @@ use eyre::{Result, bail};
use crate::i18n::fl;
pub async fn run(settings: &Settings) -> Result<()> {
let meta = Settings::meta_store().await?;
let meta = settings.meta_store().await?;
let cli_token = meta.session_token().await?;
let hub_token = meta.hub_session_token().await?;
@@ -37,11 +37,11 @@ pub async fn run(settings: &Settings) -> Result<()> {
)
.await?;
atuin_client::hub::save_session(&token).await?;
atuin_client::hub::save_session(&token, settings).await?;
println!("{}", fl!("link-hub-complete"));
}
atuin_client::hub::link_account(&hub_address, &cli_token).await?;
atuin_client::hub::link_account(&hub_address, &cli_token, settings).await?;
println!("{}", fl!("link-success"));
Ok(())
@@ -5,7 +5,7 @@ use atuin_client::record::sqlite_store::SqliteStore;
use atuin_client::record::sync::{ClientSource, SyncError, SyncSession};
use atuin_client::settings::{Settings, SyncAuth};
use atuin_common::encryption::paseto_v4;
use atuin_common::utils::env_nonempty;
use atuin_common::env::var_nonempty;
use clap::Parser;
use eyre::{Context, Result, bail};
use rpassword::prompt_password;
@@ -82,7 +82,7 @@ impl Cmd {
fn scripted_key(&self) -> Option<SecretString> {
self.key
.clone()
.or_else(|| env_nonempty(KEY_ENV)?.into_string().ok().map(SecretString::from))
.or_else(|| var_nonempty(KEY_ENV)?.into_string().ok().map(SecretString::from))
}
/// Hub login: use the browser flow unless the username was provided for headless use.
@@ -114,7 +114,7 @@ impl Cmd {
}
};
let meta = Settings::meta_store().await?;
let meta = settings.meta_store().await?;
let is_hub_token =
auth_type.as_deref() == Some("hub") || atuin_client::meta::is_hub_token(&session);
@@ -139,7 +139,7 @@ impl Cmd {
// Silently attempt to link CLI account to Hub if one exists
if let Ok(cli_token) = settings.session_token().await
&& let Err(e) = atuin_client::hub::link_account(&endpoint, &cli_token).await
&& let Err(e) = atuin_client::hub::link_account(&endpoint, &cli_token, settings).await
{
tracing::debug!("Could not link CLI account to Hub: {}", e);
}
@@ -162,7 +162,7 @@ impl Cmd {
match response {
AuthResponse::Success { session, .. } => {
Settings::meta_store().await?.save_session(&session).await?;
settings.meta_store().await?.save_session(&session).await?;
}
AuthResponse::TwoFactorRequired => {
// Legacy server doesn't support 2FA, so this shouldn't happen.
@@ -174,7 +174,7 @@ impl Cmd {
Ok(())
}
async fn ensure_hub_session(&self, _settings: &Settings, hub_address: &url::Url) -> Result<()> {
async fn ensure_hub_session(&self, settings: &Settings, hub_address: &url::Url) -> Result<()> {
tracing::info!("Authenticating with Atuin Hub...");
let mut session = atuin_client::hub::HubAuthSession::start(hub_address).await?;
@@ -201,7 +201,7 @@ impl Cmd {
tracing::info!("Authentication complete, saving session token");
atuin_client::hub::save_session(&token).await?;
atuin_client::hub::save_session(&token, settings).await?;
Ok(())
}
@@ -278,7 +278,7 @@ async fn store_key(settings: &Settings, store: &SqliteStore, key: &paseto_v4::Ke
println!("\n{}", fl!("login-reencrypting"));
store.re_encrypt(&current_key, key).await?;
crate::command::client::store::invalidate_ai_sessions().await;
crate::command::client::store::invalidate_ai_sessions(settings).await;
println!("{}", fl!("login-writing-key"));
key.overwrite_path(key_path)?;
@@ -313,7 +313,7 @@ async fn verify_key_against_remote(
None => return store_key(settings, store, &key).await,
Some(SyncError::WrongKey) => {
if !interactive {
logout_wrong_key().await;
logout_wrong_key(settings).await;
}
println!("\n{}", fl!("login-key-mismatch"));
@@ -333,7 +333,7 @@ async fn verify_key_against_remote(
}
}
// A blank line or exhausted stdin both mean "give up".
_ => logout_wrong_key().await,
_ => logout_wrong_key(settings).await,
}
}
Some(e) => {
@@ -349,8 +349,8 @@ async fn verify_key_against_remote(
/// Roll back the saved session so the user is not left in a half-authenticated
/// state with a key that can't read the data, then exit.
async fn logout_wrong_key() -> ! {
if let Ok(meta) = Settings::meta_store().await {
async fn logout_wrong_key(settings: &Settings) -> ! {
if let Ok(meta) = settings.meta_store().await {
let _ = meta.delete_session().await;
let _ = meta.delete_hub_session().await;
}
@@ -1,5 +1,6 @@
use atuin_client::settings::Settings;
use eyre::Result;
pub async fn run() -> Result<()> {
atuin_client::logout::logout().await
pub async fn run(settings: &Settings) -> Result<()> {
atuin_client::logout::logout(settings).await
}
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more