grox 0.10.0

Command-line tool that searches for regex matches in a file tree.
Documentation
use std::env;
use std::error::Error;
use std::fs::remove_file;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::exit;

use clap::Parser;
use regex::Regex;
use tiny_bail::or_return_quiet;

#[derive(Parser)]
#[command(
    name = "grox",
    version = env!("CARGO_PKG_VERSION"),
    about = "Search a file tree for regex matches."
)]
struct Args {
    #[arg(help = "Search pattern.")]
    pattern: String,
    #[arg(short, default_value = ".", help = "Starting directory.")]
    directory: String,
    #[arg(short, help = "Restrict search to files whose names match this pattern.")]
    file_pattern: Option<String>,
    #[arg(short = 'x', action = clap::ArgAction::Append, help = "Directory excluded from the search.  Can be specified multiple times.")]
    excluded_dirs: Vec<String>,
    #[arg(short = 'p', help = "Maximum search depth.  Defaults to no maximum.")]
    depth: Option<usize>,
    #[arg(short = 'i', help = "Search hidden files/directories.")]
    search_hidden: bool,
    #[arg(short, help = "Show names of matching files only.")]
    names_only: bool,
    #[arg(short, help = "Open the file for the specified result.")]
    line: Option<usize>,
    #[arg(short, help = "Editor to use for opening files.")]
    editor: Option<String>,
    #[arg(short, help = "Clear the history first")]
    clear: bool,
}

fn main() -> Result<(), Box<dyn Error>> {
    let args = Args::parse();

    if args.clear {
        clear_history();
    } else {
        maybe_load_from_history(&args);
    }

    let pattern = Regex::new(&args.pattern)?;
    let file_pattern = args.file_pattern.as_deref().map(Regex::new).transpose()?;
    let directory = PathBuf::from(args.directory.clone());
    let to_tty = atty::is(atty::Stream::Stdout);

    let mut builder = grox::Builder::new(&pattern, &directory);
    if let Some(ref pattern) = file_pattern {
        builder.set_file_pattern(pattern);
    }
    for excluded_dir in &args.excluded_dirs {
        builder.exclude_dir(excluded_dir);
    }
    if let Some(depth) = args.depth {
        builder.set_depth(depth);
    }
    if to_tty {
        builder.set_flags(grox::USE_COLOR);
    }
    if args.search_hidden {
        builder.set_flags(grox::SEARCH_HIDDEN);
    }
    if args.names_only {
        builder.set_flags(grox::SINGLE_MATCH);
    }

    Ok(process_matches(builder.build()?, &args, &directory, to_tty)?)
}

fn clear_history() {
    let history_file = or_return_quiet!(history_file_path());
    remove_file(&history_file).ok();
}

fn maybe_load_from_history(args: &Args) {
    let line = or_return_quiet!(args.line);
    let history = or_return_quiet!(get_history());
    if line >= history.locations.len() {
        return;
    }

    let directory = or_return_quiet!(PathBuf::from(&args.directory).canonicalize());
    let rebaser = grox::Rebaser::new(&directory, &args.excluded_dirs);
    let mut excluded_dirs = or_return_quiet!(rebaser
        .map(|p| p.map(|p| p.to_string_lossy().to_string()))
        .collect::<io::Result<Vec<String>>>());
    excluded_dirs.sort();

    if directory.to_string_lossy() != history.start
        || excluded_dirs != history.excluded_dirs
        || args.pattern != history.pattern
        || args.file_pattern != history.file_pattern
        || args.names_only != history.names_only
        || args.search_hidden != history.search_hidden
    {
        return;
    }

    let location = &history.locations[line];
    open_file(&location.path, location.line, args.editor.as_deref());
}

fn process_matches(searcher: grox::Searcher, args: &Args, directory: &Path, to_tty: bool) -> io::Result<()> {
    let mut history = if to_tty && args.line.is_none() {
        grox::history::History::build(
            directory,
            args.depth,
            &args.pattern,
            args.file_pattern.as_deref(),
            args.names_only,
            args.search_hidden,
            &args.excluded_dirs,
        )
        .ok()
    } else {
        None
    };

    let mut stdout = io::stdout();

    for (count, location) in searcher.enumerate() {
        let file = location.file.to_string_lossy();

        if let Some(line) = args.line {
            if line == count {
                open_file(&file, location.line, args.editor.as_deref());
            }
            continue;
        }

        let msg = if args.names_only {
            format!("({count}) {file}")
        } else {
            format!("({count}) {file}:{}: {}", location.line, location.text)
        };

        if to_tty {
            println!("{msg}");
            if let Some(ref mut history) = history {
                history.add_location(&location.file, location.line).unwrap();
            }
        } else if print_message(&mut stdout, msg).is_err() {
            return Ok(());
        }
    }

    if let Some(history) = history {
        return save_history(history);
    }
    Ok(())
}

fn get_history() -> Option<grox::history::History> {
    let history_file = history_file_path()?;
    grox::history::History::from_file(&history_file).ok()
}

fn save_history(history: grox::history::History) -> io::Result<()> {
    if let Some(history_file) = history_file_path() {
        history.save(&history_file)
    } else {
        Ok(())
    }
}

fn history_file_path() -> Option<PathBuf> {
    let home_dir = home::home_dir()?;
    Some(home_dir.join(".grox_history.json"))
}

fn print_message(stdout: &mut io::Stdout, msg: String) -> io::Result<()> {
    stdout.write_all(msg.as_bytes())?;
    stdout.write_all("\n".as_bytes())?;
    stdout.flush()?;
    Ok(())
}

fn open_file(file: &str, line: usize, specified_editor: Option<&str>) -> ! {
    let openers: [Box<dyn grox::open::Opener>; 7] = [
        Box::new(grox::open::LessOpener),
        Box::new(grox::open::ViOpener),
        Box::new(grox::open::EmacsOpener),
        Box::new(grox::open::CodeOpener),
        Box::new(grox::open::XedOpener),
        Box::new(grox::open::SublimeOpener),
        Box::new(grox::open::DefaultOpener),
    ];

    let editor = if let Some(e) = specified_editor {
        e.to_string()
    } else if let Ok(e) = env::var("EDITOR") {
        e
    } else {
        "less".to_string()
    };

    for opener in openers {
        if opener.handles_editor(&editor) {
            grox::open::open(opener.as_ref(), editor, file, line);
            eprintln!("execvp failed: {}", io::Error::last_os_error());
            break;
        }
    }

    exit(1);
}