patholog 1.0.0

Diagnose, explain, clean, and safely repair PATH behavior.
Documentation
use std::collections::HashSet;
use std::path::Path;

use crate::doctor::diagnose_path_with_policy;
use crate::model::{
    Diagnostic, IssueKind, PathVariable, PlatformMode, ResolutionReport, WhyNotReport,
};
use crate::normalize::comparison_key;
use crate::path_env::parse_path;
use crate::platform::resolve_platform_rules;
use crate::policy::PathPolicy;
use crate::resolve::resolve_command;

pub(crate) struct WhyNotOptions<'a> {
    pub(crate) path_value: &'a str,
    pub(crate) command: &'a str,
    pub(crate) platform_mode: PlatformMode,
    pub(crate) pathext: Option<&'a str>,
    pub(crate) cwd: &'a Path,
    pub(crate) home_dir: Option<&'a Path>,
    pub(crate) user_profile_dir: Option<&'a Path>,
}

pub(crate) fn analyze_why_not(options: &WhyNotOptions<'_>) -> WhyNotReport {
    let resolution = resolve_command(
        options.path_value,
        options.command,
        options.platform_mode,
        options.pathext,
        options.cwd,
        true,
    );
    let path_report = diagnose_path_with_policy(
        options.path_value,
        options.platform_mode,
        options.pathext,
        PathVariable::Path,
        &PathPolicy::default(),
    );
    let path_diagnostics = path_report
        .diagnostics
        .into_iter()
        .filter(is_path_health_blocker)
        .collect::<Vec<_>>();
    let missing_common_dirs = missing_common_tool_directories(options);
    let advice = advice_for(&resolution, &path_diagnostics, &missing_common_dirs);

    WhyNotReport {
        command: resolution.command,
        candidates: resolution.candidates,
        searched_directories: resolution.searched_directories,
        related_hints: resolution.related_hints,
        path_diagnostics,
        advice,
    }
}

fn is_path_health_blocker(diagnostic: &Diagnostic) -> bool {
    matches!(
        diagnostic.kind,
        IssueKind::Empty | IssueKind::Missing | IssueKind::NotDirectory | IssueKind::Unreadable
    )
}

fn advice_for(
    resolution: &ResolutionReport,
    path_diagnostics: &[Diagnostic],
    missing_common_dirs: &[String],
) -> Vec<String> {
    if resolution.candidates.iter().any(|candidate| candidate.wins) {
        return vec!["The exact command is already available from PATH.".to_owned()];
    }

    let mut advice = Vec::new();
    advice.push(
        "Check that the command is installed and that its executable directory is present in PATH."
            .to_owned(),
    );
    if !path_diagnostics.is_empty() {
        advice.push(
            "Review the PATH diagnostics above; broken entries can hide expected tools.".to_owned(),
        );
    }
    if !resolution.related_hints.is_empty() {
        advice.push(
            "A related executable name was found; verify the exact command name you meant to run."
                .to_owned(),
        );
    }
    if !missing_common_dirs.is_empty() {
        advice.push(format!(
            "Common tool directories absent from PATH: {}.",
            missing_common_dirs.join(", ")
        ));
    }
    advice.push(
        "If the tool is installed in a custom location, add that directory through your shell startup configuration."
            .to_owned(),
    );
    advice
}

fn missing_common_tool_directories(options: &WhyNotOptions<'_>) -> Vec<String> {
    let rules = resolve_platform_rules(options.platform_mode, options.pathext);
    let path_keys = parse_path(options.path_value, options.platform_mode, options.pathext)
        .into_iter()
        .filter(|entry| !entry.is_empty)
        .map(|entry| entry.comparison_key)
        .collect::<HashSet<_>>();

    common_tool_directories(options, rules.mode)
        .into_iter()
        .filter(|directory| !path_keys.contains(&comparison_key(directory, &rules)))
        .collect()
}

fn common_tool_directories(
    options: &WhyNotOptions<'_>,
    platform_mode: PlatformMode,
) -> Vec<String> {
    match platform_mode {
        PlatformMode::Windows => windows_common_tool_directories(options),
        PlatformMode::Auto | PlatformMode::Posix => posix_common_tool_directories(options),
    }
}

fn posix_common_tool_directories(options: &WhyNotOptions<'_>) -> Vec<String> {
    let mut directories = Vec::new();
    if matches!(
        options.command,
        "cargo" | "clippy" | "rustc" | "rustfmt" | "rustup"
    ) {
        push_home_child(&mut directories, options.home_dir, ".cargo/bin");
    }
    if matches!(
        options.command,
        "python" | "python3" | "pip" | "pip3" | "pyenv"
    ) {
        push_home_child(&mut directories, options.home_dir, ".pyenv/shims");
    }
    push_home_child(&mut directories, options.home_dir, ".local/bin");
    push_unique(&mut directories, "/opt/homebrew/bin".to_owned());
    push_unique(&mut directories, "/usr/local/bin".to_owned());
    directories
}

fn windows_common_tool_directories(options: &WhyNotOptions<'_>) -> Vec<String> {
    let mut directories = Vec::new();
    if matches!(
        options.command,
        "cargo" | "clippy" | "rustc" | "rustfmt" | "rustup"
    ) {
        push_home_child(&mut directories, options.user_profile_dir, ".cargo/bin");
    }
    push_home_child(
        &mut directories,
        options.user_profile_dir,
        "AppData/Local/Microsoft/WindowsApps",
    );
    directories
}

fn push_home_child(directories: &mut Vec<String>, home_dir: Option<&Path>, child: &str) {
    if let Some(home_dir) = home_dir {
        push_unique(directories, home_dir.join(child).display().to_string());
    }
}

fn push_unique(values: &mut Vec<String>, value: String) {
    if !values.contains(&value) {
        values.push(value);
    }
}

#[cfg(test)]
#[path = "why_not/tests.rs"]
mod tests;