whi 0.8.1

Stupid simple PATH management
Documentation
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::{self, BufRead, BufWriter, StdoutLock, Write};
use std::path::{Path, PathBuf};

use crate::cli::args::Args;
use crate::config::runtime::Config;
use crate::io::output::OutputFormatter;
use crate::path::fuzzy::FuzzyMatcher;
use crate::path::searcher::PathSearcher;
use crate::search::result::{ExecutableCheck, SearchResult};

pub(super) use crate::commands::support::path_support::{
    output_path, should_use_color, write_snapshot_safe,
};

fn write_directory_listing(
    searcher: &PathSearcher,
    args: &Args,
    out: &mut BufWriter<StdoutLock<'_>>,
) -> i32 {
    let num_dirs = searcher.dirs().len();
    if num_dirs > 999 {
        if !args.silent {
            eprintln!("Error: PATH has {num_dirs} entries (max 999 supported)");
        }
        return 3;
    }

    for (idx, dir) in searcher.dirs().iter().enumerate() {
        if args.no_index {
            writeln!(out, "{}", dir.display()).ok();
        } else {
            writeln!(out, "{:>4} {}", format!("[{}]", idx + 1), dir.display()).ok();
        }
    }
    out.flush().ok();
    0
}

fn write_query_results(
    searcher: &PathSearcher,
    args: &Args,
    config: &Config,
    out: &mut BufWriter<StdoutLock<'_>>,
    names: Vec<String>,
) -> i32 {
    let mut all_found = true;
    let stderr = io::stderr();
    let mut err = BufWriter::new(stderr.lock());

    let use_color = should_use_color(args, super::atty::is(super::atty::Stream::Stdout));
    let mut formatter = OutputFormatter::new(use_color, args.print0);

    for name in names {
        let use_fuzzy = config.search.executable_search_fuzzy ^ args.swap_fuzzy;

        let results = if !name.contains('/') && use_fuzzy {
            search_name_fuzzy(searcher, &name, args)
        } else {
            search_name(searcher, &name, args)
        };

        if results.is_empty() {
            all_found = false;

            if !args.silent && !args.quiet {
                writeln!(err, "{name}: not found").ok();
            }
            continue;
        }

        let max_index = results.iter().map(|r| r.path_index).max().unwrap_or(0);
        if max_index > 999 {
            if !args.silent {
                eprintln!("Error: PATH index {max_index} exceeds max 999");
            }
            return 3;
        }

        if !name.contains('/') && use_fuzzy {
            let mut by_index: BTreeMap<usize, Vec<&SearchResult>> = BTreeMap::new();
            for result in &results {
                by_index.entry(result.path_index).or_default().push(result);
            }

            for index_results in by_index.values_mut() {
                index_results.sort_by(|a, b| {
                    let name_a = a.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                    let name_b = b.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                    name_a.cmp(name_b)
                });
            }

            let mut seen_names: HashSet<String> = HashSet::new();

            for index_results in by_index.into_values() {
                for result in &index_results {
                    let file_name = result
                        .path
                        .file_name()
                        .and_then(|n| n.to_str())
                        .unwrap_or("");

                    let is_winner = seen_names.insert(file_name.to_string());

                    if !args.all && !args.full && !is_winner {
                        continue;
                    }

                    formatter
                        .write_result(
                            out,
                            result,
                            is_winner,
                            args.follow_symlinks,
                            !args.no_index,
                            3,
                        )
                        .ok();
                }
            }
        } else {
            for (i, result) in results.iter().enumerate() {
                let is_winner = i == 0;

                formatter
                    .write_result(
                        out,
                        result,
                        is_winner,
                        args.follow_symlinks,
                        !args.no_index,
                        3,
                    )
                    .ok();

                if (!args.all && !args.full) || args.one {
                    break;
                }
            }
        }

        if args.full {
            writeln!(out).ok();

            let match_indices: HashSet<usize> = results.iter().map(|r| r.path_index).collect();

            for (idx, dir) in searcher.dirs().iter().enumerate() {
                let path_index = idx + 1;
                let has_match = match_indices.contains(&path_index);

                if !args.no_index {
                    write!(out, "{:>4} ", format!("[{}]", path_index)).ok();
                }

                if use_color && has_match {
                    writeln!(out, "\x1b[33m{}\x1b[0m", dir.display()).ok();
                } else {
                    writeln!(out, "{}", dir.display()).ok();
                }
            }
        }
    }

    out.flush().ok();
    err.flush().ok();

    i32::from(!all_found)
}

pub(super) fn run_query(
    searcher: &PathSearcher,
    args: &Args,
    config: &Config,
    out: &mut BufWriter<StdoutLock<'_>>,
) -> i32 {
    let names = get_names(args);
    if names.is_empty() {
        return write_directory_listing(searcher, args, out);
    }

    write_query_results(searcher, args, config, out, names)
}

pub(super) fn get_names(args: &Args) -> Vec<String> {
    if !args.names.is_empty() {
        return args.names.clone();
    }

    if !super::atty::is(super::atty::Stream::Stdin) {
        let stdin = io::stdin();
        let mut names = Vec::new();
        for line in stdin.lock().lines().map_while(Result::ok) {
            let trimmed = line.trim();
            if !trimmed.is_empty() && !trimmed.starts_with('#') {
                names.push(trimmed.to_string());
            }
        }
        return names;
    }

    Vec::new()
}

pub(super) fn search_name(searcher: &PathSearcher, name: &str, args: &Args) -> Vec<SearchResult> {
    if name.contains('/') {
        let path = PathBuf::from(name);
        if let Some(result) = check_path(&path, args, 0) {
            return vec![result];
        }
        return vec![];
    }

    let mut results = Vec::new();
    let search_all = args.all || args.full;

    for (idx, dir) in searcher.dirs().iter().enumerate() {
        let candidate = dir.join(name);
        if let Some(result) = check_path(&candidate, args, idx + 1) {
            results.push(result);

            if !search_all {
                break;
            }
        }
    }

    results
}

fn check_dir_entry(entry: &fs::DirEntry, args: &Args, path_index: usize) -> Option<SearchResult> {
    let path = entry.path();
    let metadata = fs::metadata(&path).ok()?;

    if !metadata.is_file() && !args.show_nonexec {
        return None;
    }

    let checker = ExecutableCheck::with_metadata(&path, metadata.clone());

    if !checker.is_executable() && !args.show_nonexec {
        return None;
    }

    let canonical_path = if args.follow_symlinks {
        fs::canonicalize(&path).ok()
    } else {
        None
    };

    let file_metadata = if args.stat {
        checker.get_file_metadata()
    } else {
        None
    };

    Some(SearchResult {
        path,
        canonical_path,
        metadata: file_metadata,
        path_index,
    })
}

fn search_name_fuzzy(searcher: &PathSearcher, query: &str, args: &Args) -> Vec<SearchResult> {
    use std::ffi::OsStr;

    let matcher = FuzzyMatcher::new(query);
    let mut results = Vec::new();

    for (idx, dir) in searcher.dirs().iter().enumerate() {
        let Ok(entries) = fs::read_dir(dir) else {
            continue;
        };

        for entry in entries.flatten() {
            let path = entry.path();
            let Some(filename) = path.file_name().and_then(OsStr::to_str) else {
                continue;
            };

            if matcher.matches(&PathBuf::from(filename))
                && let Some(result) = check_dir_entry(&entry, args, idx + 1)
            {
                results.push(result);
            }
        }
    }

    results
}

fn check_path(path: &Path, args: &Args, path_index: usize) -> Option<SearchResult> {
    let checker = ExecutableCheck::new(path);

    if !checker.exists() {
        return None;
    }

    if !checker.is_executable() && !args.show_nonexec {
        return None;
    }

    let canonical_path = if args.follow_symlinks {
        fs::canonicalize(path).ok()
    } else {
        None
    };

    let metadata = if args.stat {
        checker.get_file_metadata()
    } else {
        None
    };

    Some(SearchResult {
        path: path.to_path_buf(),
        canonical_path,
        metadata,
        path_index,
    })
}