use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::{
backend::locale_hint,
config::{config_string, preparse_load_fzf_default_opts, yuru_config_source, ConfigSource},
input::default_source_command,
shell_words::split_shell_words,
};
pub(crate) fn print_doctor_report() -> Result<()> {
let mut stdout = io::stdout().lock();
let exe = std::env::current_exe().context("failed to resolve current executable")?;
let config = yuru_config_source();
let default_lang = doctor_default_lang(config.as_ref()).unwrap_or_else(|| "plain".to_string());
let fzf_mode = match preparse_load_fzf_default_opts(&[], config.as_ref()) {
Ok(mode) => format!("{mode:?}").to_ascii_lowercase(),
Err(error) => format!("unreadable ({error})"),
};
writeln!(stdout, "Yuru doctor")?;
writeln!(stdout, "ok binary: {}", exe.display())?;
writeln!(stdout, "ok version: {}", env!("CARGO_PKG_VERSION"))?;
match path_visibility(&exe) {
Some(path) => writeln!(stdout, "ok path: visible in PATH at {}", path.display())?,
None => writeln!(stdout, "warn path: binary directory is not visible in PATH")?,
}
match &config {
Some(ConfigSource::Toml(path)) => {
writeln!(stdout, "ok config: {} (toml)", path.display())?;
}
Some(ConfigSource::Legacy(path)) => {
writeln!(
stdout,
"warn config: {} (legacy shell words; migrate to config.toml)",
path.display()
)?;
}
None => {
writeln!(stdout, "warn config: missing (using compiled defaults)")?;
}
}
writeln!(stdout, "info default language: {default_lang}")?;
writeln!(
stdout,
"info fzf default opts: {}",
doctor_fzf_defaults(&fzf_mode)
)?;
writeln!(
stdout,
"info preview image protocol: {}",
doctor_preview_image_protocol(config.as_ref())
)?;
writeln!(stdout, "info locale: {}", doctor_locale())?;
writeln!(stdout, "info default command: {}", doctor_default_command())?;
writeln!(stdout, "{}", doctor_shell_integration())?;
Ok(())
}
fn path_visibility(exe: &Path) -> Option<PathBuf> {
let exe_name = exe.file_name()?;
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(exe_name))
.find(|candidate| candidate.exists())
}
pub(crate) fn doctor_default_lang(config: Option<&ConfigSource>) -> Option<String> {
match config? {
ConfigSource::Toml(path) => toml_config_default_lang(path),
ConfigSource::Legacy(path) => shell_word_default_lang(path),
}
}
fn toml_config_default_lang(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let value = content.parse::<toml::Value>().ok()?;
value
.get("defaults")
.and_then(|defaults| defaults.get("lang"))
.and_then(toml::Value::as_str)
.map(str::to_string)
}
fn shell_word_default_lang(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
find_option_value(split_shell_words(&content), "--lang")
}
fn doctor_preview_image_protocol(config: Option<&ConfigSource>) -> String {
let Some(ConfigSource::Toml(path)) = config else {
return "none".to_string();
};
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(_) => return "unreadable".to_string(),
};
let value = match content.parse::<toml::Value>() {
Ok(value) => value,
Err(_) => return "unreadable".to_string(),
};
config_string(&value, &["preview", "image_protocol"]).unwrap_or_else(|| "none".to_string())
}
fn find_option_value<I>(args: I, option: &str) -> Option<String>
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let equals_prefix = format!("{option}=");
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg.as_ref();
if let Some(value) = arg.strip_prefix(&equals_prefix) {
return Some(value.to_string());
}
if arg == option {
return args.next().map(|value| value.as_ref().to_string());
}
}
None
}
fn doctor_fzf_defaults(mode: &str) -> String {
let mut sources = Vec::new();
for name in [
"FZF_DEFAULT_OPTS_FILE",
"FZF_DEFAULT_OPTS",
"YURU_DEFAULT_OPTS_FILE",
"YURU_DEFAULT_OPTS",
] {
if std::env::var_os(name).is_some() {
sources.push(name);
}
}
if sources.is_empty() {
format!("{mode} (no default opts env)")
} else {
format!("{mode} ({})", sources.join(", "))
}
}
fn doctor_locale() -> String {
let locale = locale_hint();
if locale.is_empty() {
"(not set)".to_string()
} else {
locale
}
}
fn doctor_default_command() -> String {
default_source_command()
.map(|(name, command)| {
if command.trim().is_empty() {
format!("{name} is set but empty")
} else {
format!("{name} ({command})")
}
})
.unwrap_or_else(|| "built-in walker".to_string())
}
fn doctor_shell_integration() -> String {
match detected_shell_profile() {
Some((shell, path)) => match fs::read_to_string(&path) {
Ok(content) => format_profile_status(shell, &path, classify_shell_profile(&content)),
Err(_) => format!(
"warn shell integration: {shell} profile unreadable ({})",
path.display()
),
},
None => "warn shell integration: unknown shell/profile".to_string(),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellProfileStatus {
Current,
MissingMarker,
BrokenInvokeExpressionPipe,
PipedYuruStdin,
}
fn classify_shell_profile(content: &str) -> ShellProfileStatus {
if !content.contains("yuru shell integration") {
return ShellProfileStatus::MissingMarker;
}
if content.contains("--powershell | Invoke-Expression") {
return ShellProfileStatus::BrokenInvokeExpressionPipe;
}
if content.contains("| & $yuru") {
return ShellProfileStatus::PipedYuruStdin;
}
ShellProfileStatus::Current
}
fn format_profile_status(shell: &str, path: &Path, status: ShellProfileStatus) -> String {
match status {
ShellProfileStatus::Current => {
format!("ok shell integration: {shell} current ({})", path.display())
}
ShellProfileStatus::MissingMarker => {
format!("warn shell integration: {shell} profile missing marker ({})", path.display())
}
ShellProfileStatus::BrokenInvokeExpressionPipe => format!(
"warn shell integration: {shell} profile uses line-by-line Invoke-Expression; rerun installer ({})",
path.display()
),
ShellProfileStatus::PipedYuruStdin => format!(
"warn shell integration: {shell} profile pipes candidates into yuru; reinstall current release or newer ({})",
path.display()
),
}
}
#[cfg(not(windows))]
fn detected_shell_profile() -> Option<(&'static str, PathBuf)> {
let shell = std::env::var("SHELL").ok()?;
let home = PathBuf::from(std::env::var("HOME").ok()?);
let shell_name = Path::new(&shell).file_name()?.to_string_lossy();
match shell_name.as_ref() {
"zsh" => Some(("zsh", home.join(".zshrc"))),
"bash" => Some(("bash", home.join(".bashrc"))),
"fish" => Some((
"fish",
home.join(".config").join("fish").join("config.fish"),
)),
_ => None,
}
}
#[cfg(windows)]
fn detected_shell_profile() -> Option<(&'static str, PathBuf)> {
let home = PathBuf::from(std::env::var("USERPROFILE").ok()?);
let profiles = [
home.join("Documents")
.join("PowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
home.join("Documents")
.join("WindowsPowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
];
profiles
.into_iter()
.find(|path| path.exists())
.map(|path| ("powershell", path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_current_profile_loader() {
let profile = r#"# yuru shell integration
$env:YURU_BIN = 'C:\Users\me\AppData\Local\Yuru\bin\yuru.exe'
if (Test-Path -LiteralPath $env:YURU_BIN) {
$yuruPowerShellIntegration = (& $env:YURU_BIN --powershell) -join "`n"
Invoke-Expression $yuruPowerShellIntegration
}
# end yuru shell integration
"#;
assert_eq!(classify_shell_profile(profile), ShellProfileStatus::Current);
}
#[test]
fn classifies_old_line_by_line_profile_loader() {
let profile = r#"# yuru shell integration
$env:YURU_BIN = 'C:\Users\me\AppData\Local\Yuru\bin\yuru.exe'
if (Test-Path -LiteralPath $env:YURU_BIN) {
& $env:YURU_BIN --powershell | Invoke-Expression
}
"#;
assert_eq!(
classify_shell_profile(profile),
ShellProfileStatus::BrokenInvokeExpressionPipe
);
}
#[test]
fn classifies_old_generated_profile_script() {
let profile = r#"# yuru shell integration for PowerShell
function Invoke-YuruCompletion {
Get-YuruPathItems $root | & $yuru --scheme path -m --query $query
}
"#;
assert_eq!(
classify_shell_profile(profile),
ShellProfileStatus::PipedYuruStdin
);
}
}