use std::process::Command;
use std::{env, str::FromStr};
use atuin_client::database::Sqlite;
use atuin_client::settings::Settings;
use atuin_common::shell::{Shell, shell_name};
use 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,
pub default: String,
pub plugins: Vec<String>,
pub preexec: Option<String>,
}
impl ShellInfo {
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> {
enum PluginShellType {
Any,
Bash,
#[allow(dead_code)]
Zsh,
#[allow(dead_code)]
Fish,
#[allow(dead_code)]
Nushell,
#[allow(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 {
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 records: bool,
pub auto_sync: bool,
pub last_sync: String,
}
impl SyncInfo {
pub async fn new(settings: &Settings) -> Self {
let meta = Settings::meta_store().await.ok();
let has_hub_token = match &meta {
Some(m) => m
.hub_session_token()
.await
.ok()
.flatten()
.filter(|t| t.starts_with("atapi_"))
.is_some(),
None => false,
};
let has_cli_token = match &meta {
Some(m) => m.session_token().await.ok().flatten().is_some(),
None => false,
};
let auth_state = if has_hub_token {
"Hub (authenticated)".into()
} else if settings.is_hub_sync() && has_cli_token {
"Hub (legacy token \u{2014} run 'atuin login' to upgrade)".into()
} else if !settings.is_hub_sync() && has_cli_token {
"Self-hosted (authenticated)".into()
} else {
"Not authenticated".into()
};
Self {
auth_state,
auto_sync: settings.auto_sync,
records: settings.sync.records,
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,
pub sync: Option<SyncInfo>,
pub sqlite_version: String,
#[serde(skip)] 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!(); 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();
if info.system.disks.iter().any(|d| d.filesystem == "zfs") {
println!("{zfs_error}");
}
info.atuin.setting_paths.verify();
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(())
}