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(crate) name: String, // best-effort, not supported on all OSes pub(crate) default: String, // Detect some shell plugins that the user has installed. // I'm just going to start with preexec/blesh pub(crate) plugins: Vec, // The preexec framework used in the current session, if Atuin is loaded. pub(crate) preexec: Option, } 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 { 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 { 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(crate) fn plugins(shell: &str, shell_process: &sysinfo::Process) -> Vec { // 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; let plugin_list: [( &str, PluginShellType, PluginProbeType, Option, ); 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 { 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(crate) 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(crate) name: String, pub(crate) filesystem: String, } #[derive(Debug, Serialize)] struct SystemInfo { pub(crate) os: String, pub(crate) arch: String, pub(crate) version: String, pub(crate) disks: Vec, } impl SystemInfo { pub(crate) 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(crate) auth_state: String, pub(crate) auto_sync: bool, pub(crate) last_sync: String, } impl SyncInfo { pub(crate) 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(crate) fn new(settings: &Settings) -> Self { Self { db: settings.db_path.clone(), record_store: settings.record_store_path.clone(), key: settings.key_path.clone(), } } pub(crate) 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(crate) version: String, pub(crate) 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(crate) sync: Option, pub(crate) sqlite_version: String, #[serde(skip)] // probably unnecessary to expose this pub(crate) setting_paths: SettingPaths, } impl AtuinInfo { pub(crate) 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(crate) atuin: AtuinInfo, pub(crate) shell: ShellInfo, pub(crate) system: SystemInfo, } impl DoctorDump { pub(crate) 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(crate) 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(()) }