yuru 0.1.8

A fast phonetic fuzzy finder for the shell
use std::fs;
use std::io::{self, IsTerminal, Read};
use std::path::Path;

use anyhow::{bail, Context, Result};
use ignore::{DirEntry, WalkBuilder};

use crate::{fields::InputRecord, options::read0_enabled, Args};

pub(crate) fn read_input_candidates(
    args: &Args,
    walker_requested: bool,
) -> Result<Vec<InputRecord>> {
    if let Some(path) = &args.input {
        return read_file_candidates(path, read0_enabled(args));
    }

    let stdin_is_terminal = io::stdin().is_terminal();
    let stdin_items = if stdin_is_terminal {
        Vec::new()
    } else {
        read_stdin_candidates(read0_enabled(args))?
    };
    if !stdin_items.is_empty() {
        return Ok(stdin_items);
    }

    if walker_requested {
        return run_walker(args);
    }

    if let Some((env_name, command)) = default_source_command() {
        if !command.trim().is_empty() {
            return run_default_command(env_name, &command, read0_enabled(args));
        }
    }

    if !stdin_is_terminal {
        return Ok(stdin_items);
    }

    run_walker(args)
}

pub(crate) fn read_stdin_candidates(read0: bool) -> Result<Vec<InputRecord>> {
    let mut input = Vec::new();
    io::stdin().read_to_end(&mut input)?;
    Ok(parse_candidate_bytes(&input, read0))
}

pub(crate) fn read_file_candidates(path: &Path, read0: bool) -> Result<Vec<InputRecord>> {
    let input =
        fs::read(path).with_context(|| format!("failed to read input file {}", path.display()))?;
    Ok(parse_candidate_bytes(&input, read0))
}

pub(crate) fn parse_candidate_bytes(input: &[u8], read0: bool) -> Vec<InputRecord> {
    if read0 {
        input
            .split(|byte| *byte == b'\0')
            .filter(|item| !item.is_empty())
            .map(|item| InputRecord::from_raw(item.to_vec()))
            .collect()
    } else {
        parse_line_records(input)
    }
}

pub(crate) fn parse_line_records(input: &[u8]) -> Vec<InputRecord> {
    if input.is_empty() {
        return Vec::new();
    }

    let mut out = Vec::new();
    let mut start = 0usize;
    for (index, byte) in input.iter().enumerate() {
        if *byte != b'\n' {
            continue;
        }
        out.push(InputRecord::from_raw(
            trim_trailing_cr(&input[start..index]).to_vec(),
        ));
        start = index + 1;
    }
    if start < input.len() {
        out.push(InputRecord::from_raw(
            trim_trailing_cr(&input[start..]).to_vec(),
        ));
    }
    out
}

fn trim_trailing_cr(input: &[u8]) -> &[u8] {
    input.strip_suffix(b"\r").unwrap_or(input)
}

pub(crate) fn default_source_command() -> Option<(&'static str, String)> {
    for env_name in ["YURU_DEFAULT_COMMAND", "FZF_DEFAULT_COMMAND"] {
        if let Ok(command) = std::env::var(env_name) {
            return Some((env_name, command));
        }
    }
    None
}

pub(crate) fn non_empty_default_source_command() -> Option<(&'static str, String)> {
    default_source_command().filter(|(_, command)| !command.trim().is_empty())
}

fn run_default_command(env_name: &str, command: &str, read0: bool) -> Result<Vec<InputRecord>> {
    let output = default_command_process(command)
        .output()
        .with_context(|| format!("failed to run {env_name}: {command}"))?;

    if !output.status.success() {
        bail!("{env_name} exited with {}", output.status);
    }

    Ok(parse_candidate_bytes(&output.stdout, read0))
}

#[cfg(not(windows))]
pub(crate) fn default_command_process(command: &str) -> std::process::Command {
    let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
    let mut process = std::process::Command::new(shell);
    process.arg("-c").arg(command);
    process
}

#[cfg(windows)]
pub(crate) fn default_command_process(command: &str) -> std::process::Command {
    let shell =
        std::env::var("YURU_WINDOWS_SHELL").unwrap_or_else(|_| "powershell.exe".to_string());
    let mut process = std::process::Command::new(shell);
    process
        .arg("-NoLogo")
        .arg("-NoProfile")
        .arg("-Command")
        .arg(command);
    process
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct WalkerOptions {
    pub(crate) files: bool,
    pub(crate) dirs: bool,
    pub(crate) follow: bool,
    pub(crate) hidden: bool,
}

fn run_walker(args: &Args) -> Result<Vec<InputRecord>> {
    let options = parse_walker_options(&args.walker)?;
    let skips = parse_walker_skip(&args.walker_skip);
    let mut out = Vec::new();

    for root in &args.walker_roots {
        let mut builder = WalkBuilder::new(root);
        builder
            .follow_links(options.follow)
            .hidden(!options.hidden)
            .ignore(true)
            .git_ignore(true)
            .git_global(true)
            .parents(true)
            .require_git(false);
        let skips = skips.clone();
        builder.filter_entry(move |entry| walker_entry_allowed(entry, &skips, options.hidden));

        for entry in builder.build() {
            let entry = match entry {
                Ok(entry) => entry,
                Err(error) if walker_error_is_skippable(&error) => continue,
                Err(error) => return Err(error.into()),
            };
            if entry.depth() == 0 {
                continue;
            }

            let Some(file_type) = entry.file_type() else {
                continue;
            };
            let include =
                file_type.is_file() && options.files || file_type.is_dir() && options.dirs;
            if include {
                out.push(InputRecord::from_raw(
                    display_walked_path(root, entry.path()).into_bytes(),
                ));
            }
        }
    }

    Ok(out)
}

fn walker_error_is_skippable(error: &ignore::Error) -> bool {
    if ignore_error_is_loop(error) {
        return true;
    }

    error.io_error().is_some_and(|io_error| {
        matches!(
            io_error.kind(),
            io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
        )
    })
}

fn ignore_error_is_loop(error: &ignore::Error) -> bool {
    match error {
        ignore::Error::Loop { .. } => true,
        ignore::Error::Partial(errors) => errors.iter().any(ignore_error_is_loop),
        ignore::Error::WithLineNumber { err, .. }
        | ignore::Error::WithPath { err, .. }
        | ignore::Error::WithDepth { err, .. } => ignore_error_is_loop(err),
        _ => false,
    }
}

pub(crate) fn parse_walker_options(raw: &str) -> Result<WalkerOptions> {
    let mut options = WalkerOptions {
        files: false,
        dirs: false,
        follow: false,
        hidden: false,
    };

    for part in raw
        .split(',')
        .map(str::trim)
        .filter(|part| !part.is_empty())
    {
        match part {
            "file" => options.files = true,
            "dir" => options.dirs = true,
            "follow" => options.follow = true,
            "hidden" => options.hidden = true,
            other => bail!("unknown walker option: {other}"),
        }
    }

    Ok(options)
}

pub(crate) fn parse_walker_skip(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .map(str::to_string)
        .collect()
}

pub(crate) fn walker_entry_allowed(
    entry: &DirEntry,
    skips: &[String],
    include_hidden: bool,
) -> bool {
    if entry.depth() == 0 {
        return true;
    }

    let name = entry.file_name().to_string_lossy();
    if skips.iter().any(|skip| skip == name.as_ref()) {
        return false;
    }
    include_hidden || !name.starts_with('.')
}

fn display_walked_path(root: &Path, path: &Path) -> String {
    let relative = path.strip_prefix(root).unwrap_or(path);
    if root == Path::new(".") {
        relative.display().to_string()
    } else {
        root.join(relative).display().to_string()
    }
}