aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/command/client/doctor.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 00:54:30 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 00:54:30 +0200
commit5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8 (patch)
treec64baa8d5866c8e339eaf660dd3f94f30a3f7d8a /crates/turtle/src/command/client/doctor.rs
parentchore: Somewhat simplify sync code (diff)
downloadatuin-5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8.zip
chore: Move everything into one big crate
That helps remove duplicated code and rustc/cargo will now also show dead code correctly.
Diffstat (limited to 'crates/turtle/src/command/client/doctor.rs')
-rw-r--r--crates/turtle/src/command/client/doctor.rs412
1 files changed, 412 insertions, 0 deletions
diff --git a/crates/turtle/src/command/client/doctor.rs b/crates/turtle/src/command/client/doctor.rs
new file mode 100644
index 00000000..09fa6e77
--- /dev/null
+++ b/crates/turtle/src/command/client/doctor.rs
@@ -0,0 +1,412 @@
+use std::process::Command;
+use std::{env, str::FromStr};
+
+use crate::atuin_client::database::Sqlite;
+use crate::atuin_client::settings::Settings;
+use crate::atuin_common::shell::{Shell, shell_name};
+use crate::atuin_common::utils;
+use colored::Colorize;
+use eyre::Result;
+use serde::Serialize;
+
+use sysinfo::{Disks, System, get_current_pid};
+
+#[derive(Debug, Serialize)]
+struct ShellInfo {
+ pub name: String,
+
+ // best-effort, not supported on all OSes
+ pub default: String,
+
+ // Detect some shell plugins that the user has installed.
+ // I'm just going to start with preexec/blesh
+ pub plugins: Vec<String>,
+
+ // The preexec framework used in the current session, if Atuin is loaded.
+ pub preexec: Option<String>,
+}
+
+impl ShellInfo {
+ // HACK ALERT!
+ // Many of the shell vars we need to detect are not exported :(
+ // So, we're going to run a interactive session and directly check the
+ // variable. There's a chance this won't work, so it should not be fatal.
+ //
+ // Every shell we support handles `shell -ic 'command'`
+ fn shellvar_exists(shell: &str, var: &str) -> bool {
+ let cmd = Command::new(shell)
+ .args([
+ "-ic",
+ format!("[ -z ${var} ] || echo ATUIN_DOCTOR_ENV_FOUND").as_str(),
+ ])
+ .output()
+ .map_or(String::new(), |v| {
+ let out = v.stdout;
+ String::from_utf8(out).unwrap_or_default()
+ });
+
+ cmd.contains("ATUIN_DOCTOR_ENV_FOUND")
+ }
+
+ fn detect_preexec_framework(shell: &str) -> Option<String> {
+ if env::var("ATUIN_SESSION").ok().is_none() {
+ None
+ } else if shell.starts_with("bash") || shell == "sh" {
+ env::var("ATUIN_PREEXEC_BACKEND")
+ .ok()
+ .filter(|value| !value.is_empty())
+ .and_then(|atuin_preexec_backend| {
+ atuin_preexec_backend.rfind(':').and_then(|pos_colon| {
+ u32::from_str(&atuin_preexec_backend[..pos_colon])
+ .ok()
+ .is_some_and(|preexec_shlvl| {
+ env::var("SHLVL")
+ .ok()
+ .and_then(|shlvl| u32::from_str(&shlvl).ok())
+ .is_some_and(|shlvl| shlvl == preexec_shlvl)
+ })
+ .then(|| atuin_preexec_backend[pos_colon + 1..].to_string())
+ })
+ })
+ } else {
+ Some("built-in".to_string())
+ }
+ }
+
+ fn validate_plugin_blesh(
+ _shell: &str,
+ shell_process: &sysinfo::Process,
+ ble_session_id: &str,
+ ) -> Option<String> {
+ ble_session_id
+ .split('/')
+ .nth(1)
+ .and_then(|field| u32::from_str(field).ok())
+ .filter(|&blesh_pid| blesh_pid == shell_process.pid().as_u32())
+ .map(|_| "blesh".to_string())
+ }
+
+ pub fn plugins(shell: &str, shell_process: &sysinfo::Process) -> Vec<String> {
+ // consider a different detection approach if there are plugins
+ // that don't set shell vars
+
+ enum PluginShellType {
+ Any,
+ Bash,
+
+ // Note: these are currently unused
+ #[expect(dead_code)]
+ Zsh,
+ #[expect(dead_code)]
+ Fish,
+ #[expect(dead_code)]
+ Nushell,
+ #[expect(dead_code)]
+ Xonsh,
+ }
+
+ enum PluginProbeType {
+ EnvironmentVariable(&'static str),
+ InteractiveShellVariable(&'static str),
+ }
+
+ type PluginValidator = fn(&str, &sysinfo::Process, &str) -> Option<String>;
+
+ let plugin_list: [(
+ &str,
+ PluginShellType,
+ PluginProbeType,
+ Option<PluginValidator>,
+ ); 3] = [
+ (
+ "atuin",
+ PluginShellType::Any,
+ PluginProbeType::EnvironmentVariable("ATUIN_SESSION"),
+ None,
+ ),
+ (
+ "blesh",
+ PluginShellType::Bash,
+ PluginProbeType::EnvironmentVariable("BLE_SESSION_ID"),
+ Some(Self::validate_plugin_blesh),
+ ),
+ (
+ "bash-preexec",
+ PluginShellType::Bash,
+ PluginProbeType::InteractiveShellVariable("bash_preexec_imported"),
+ None,
+ ),
+ ];
+
+ plugin_list
+ .into_iter()
+ .filter(|(_, shell_type, _, _)| match shell_type {
+ PluginShellType::Any => true,
+ PluginShellType::Bash => shell.starts_with("bash") || shell == "sh",
+ PluginShellType::Zsh => shell.starts_with("zsh"),
+ PluginShellType::Fish => shell.starts_with("fish"),
+ PluginShellType::Nushell => shell.starts_with("nu"),
+ PluginShellType::Xonsh => shell.starts_with("xonsh"),
+ })
+ .filter_map(|(plugin, _, probe_type, validator)| -> Option<String> {
+ match probe_type {
+ PluginProbeType::EnvironmentVariable(env) => {
+ env::var(env).ok().filter(|value| !value.is_empty())
+ }
+ PluginProbeType::InteractiveShellVariable(shellvar) => {
+ ShellInfo::shellvar_exists(shell, shellvar).then_some(String::default())
+ }
+ }
+ .and_then(|value| {
+ validator.map_or_else(
+ || Some(plugin.to_string()),
+ |validator| validator(shell, shell_process, &value),
+ )
+ })
+ })
+ .collect()
+ }
+
+ pub fn new() -> Self {
+ // TODO: rework to use crate::atuin_common::Shell
+
+ let sys = System::new_all();
+
+ let process = sys
+ .process(get_current_pid().expect("Failed to get current PID"))
+ .expect("Process with current pid does not exist");
+
+ let parent = sys
+ .process(process.parent().expect("Atuin running with no parent!"))
+ .expect("Process with parent pid does not exist");
+
+ let name = shell_name(Some(parent));
+
+ let plugins = ShellInfo::plugins(name.as_str(), parent);
+
+ let default = Shell::default_shell().unwrap_or(Shell::Unknown).to_string();
+
+ let preexec = Self::detect_preexec_framework(name.as_str());
+
+ Self {
+ name,
+ default,
+ plugins,
+ preexec,
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+struct DiskInfo {
+ pub name: String,
+ pub filesystem: String,
+}
+
+#[derive(Debug, Serialize)]
+struct SystemInfo {
+ pub os: String,
+
+ pub arch: String,
+
+ pub version: String,
+ pub disks: Vec<DiskInfo>,
+}
+
+impl SystemInfo {
+ pub fn new() -> Self {
+ let disks = Disks::new_with_refreshed_list();
+ let disks = disks
+ .list()
+ .iter()
+ .map(|d| DiskInfo {
+ name: d.name().to_os_string().into_string().unwrap(),
+ filesystem: d.file_system().to_os_string().into_string().unwrap(),
+ })
+ .collect();
+
+ Self {
+ os: System::name().unwrap_or_else(|| "unknown".to_string()),
+ arch: System::cpu_arch().unwrap_or_else(|| "unknown".to_string()),
+ version: System::os_version().unwrap_or_else(|| "unknown".to_string()),
+ disks,
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+struct SyncInfo {
+ pub auth_state: String,
+ pub auto_sync: bool,
+
+ pub last_sync: String,
+}
+
+impl SyncInfo {
+ pub async fn new(settings: &Settings) -> Self {
+ // Build auth state description from raw token state without calling
+ // resolve_sync_auth(), which has side effects (token migration cleanup)
+ // that a diagnostic command should not trigger.
+ let meta = Settings::meta_store().await.ok();
+ let has_cli_token = match &meta {
+ Some(m) => m.session_token().await.ok().flatten().is_some(),
+ None => false,
+ };
+
+ let auth_state = if has_cli_token {
+ "Self-hosted (authenticated)".into()
+ } else {
+ "Not authenticated".into()
+ };
+
+ Self {
+ auth_state,
+ auto_sync: settings.auto_sync,
+ last_sync: Settings::last_sync()
+ .await
+ .map_or_else(|_| "no last sync".to_string(), |v| v.to_string()),
+ }
+ }
+}
+
+#[derive(Debug)]
+struct SettingPaths {
+ db: String,
+ record_store: String,
+ key: String,
+}
+
+impl SettingPaths {
+ pub fn new(settings: &Settings) -> Self {
+ Self {
+ db: settings.db_path.clone(),
+ record_store: settings.record_store_path.clone(),
+ key: settings.key_path.clone(),
+ }
+ }
+
+ pub fn verify(&self) {
+ let paths = vec![
+ ("ATUIN_DB_PATH", &self.db),
+ ("ATUIN_RECORD_STORE", &self.record_store),
+ ("ATUIN_KEY", &self.key),
+ ];
+
+ for (path_env_var, path) in paths {
+ if utils::broken_symlink(path) {
+ eprintln!(
+ "{path} (${path_env_var}) is a broken symlink. This may cause issues with Atuin."
+ );
+ }
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+struct AtuinInfo {
+ pub version: String,
+ pub commit: String,
+
+ /// Whether the main Atuin sync server is in use
+ /// I'm just calling it Atuin Cloud for lack of a better name atm
+ pub sync: Option<SyncInfo>,
+
+ pub sqlite_version: String,
+
+ #[serde(skip)] // probably unnecessary to expose this
+ pub setting_paths: SettingPaths,
+}
+
+impl AtuinInfo {
+ pub async fn new(settings: &Settings) -> Self {
+ let logged_in = settings.logged_in().await.unwrap_or(false);
+
+ let sync = if logged_in {
+ Some(SyncInfo::new(settings).await)
+ } else {
+ None
+ };
+
+ let sqlite_version = match Sqlite::new("sqlite::memory:", 0.1).await {
+ Ok(db) => db
+ .sqlite_version()
+ .await
+ .unwrap_or_else(|_| "unknown".to_string()),
+ Err(_) => "error".to_string(),
+ };
+
+ Self {
+ version: crate::VERSION.to_string(),
+ commit: crate::SHA.to_string(),
+ sync,
+ sqlite_version,
+ setting_paths: SettingPaths::new(settings),
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+struct DoctorDump {
+ pub atuin: AtuinInfo,
+ pub shell: ShellInfo,
+ pub system: SystemInfo,
+}
+
+impl DoctorDump {
+ pub async fn new(settings: &Settings) -> Self {
+ Self {
+ atuin: AtuinInfo::new(settings).await,
+ shell: ShellInfo::new(),
+ system: SystemInfo::new(),
+ }
+ }
+}
+
+fn checks(info: &DoctorDump) {
+ println!(); // spacing
+ //
+ let zfs_error = "[Filesystem] ZFS is known to have some issues with SQLite. Atuin uses SQLite heavily. If you are having poor performance, there are some workarounds here: https://github.com/atuinsh/atuin/issues/952".bold().red();
+ let bash_plugin_error = "[Shell] If you are using Bash, Atuin requires that either bash-preexec or ble.sh (>= 0.4) be installed. An older ble.sh may not be detected. so ignore this if you have ble.sh >= 0.4 set up! Read more here: https://docs.atuin.sh/guide/installation/#bash".bold().red();
+ let blesh_integration_error = "[Shell] Atuin and ble.sh seem to be loaded in the session, but the integration does not seem to be working. Please check the setup in .bashrc.".bold().red();
+
+ // ZFS: https://github.com/atuinsh/atuin/issues/952
+ if info.system.disks.iter().any(|d| d.filesystem == "zfs") {
+ println!("{zfs_error}");
+ }
+
+ info.atuin.setting_paths.verify();
+
+ // Shell
+ if info.shell.name == "bash" {
+ if !info
+ .shell
+ .plugins
+ .iter()
+ .any(|p| p == "blesh" || p == "bash-preexec")
+ {
+ println!("{bash_plugin_error}");
+ }
+
+ if info.shell.plugins.iter().any(|plugin| plugin == "atuin")
+ && info.shell.plugins.iter().any(|plugin| plugin == "blesh")
+ && info.shell.preexec.as_ref().is_some_and(|val| val == "none")
+ {
+ println!("{blesh_integration_error}");
+ }
+ }
+}
+
+pub async fn run(settings: &Settings) -> Result<()> {
+ println!("{}", "Atuin Doctor".bold());
+ println!("Checking for diagnostics");
+ let dump = DoctorDump::new(settings).await;
+
+ checks(&dump);
+
+ let dump = serde_json::to_string_pretty(&dump)?;
+
+ println!("\nPlease include the output below with any bug reports or issues\n");
+ println!("{dump}");
+
+ Ok(())
+}