quinjet 0.0.72

A fast, live, keyboard-first Git source-control interface for the terminal
use std::path::PathBuf;

use clap::ValueEnum;
use grep_regex::{RegexMatcher, RegexMatcherBuilder};
use grep_searcher::sinks::Bytes;
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder};
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SearchMode {
    #[default]
    Name,
    Contents,
    Both,
}

impl SearchMode {
    pub(crate) const ALL: [Self; 3] = [Self::Name, Self::Contents, Self::Both];

    pub(crate) const fn label(self) -> &'static str {
        match self {
            Self::Name => "Name",
            Self::Contents => "Contents",
            Self::Both => "Both",
        }
    }

    pub(crate) const fn cycle(self) -> Self {
        match self {
            Self::Name => Self::Contents,
            Self::Contents => Self::Both,
            Self::Both => Self::Name,
        }
    }

    pub(crate) const fn previous(self) -> Self {
        match self {
            Self::Name => Self::Both,
            Self::Contents => Self::Name,
            Self::Both => Self::Contents,
        }
    }

    pub(crate) const fn includes_name(self) -> bool {
        matches!(self, Self::Name | Self::Both)
    }

    pub(crate) const fn includes_contents(self) -> bool {
        matches!(self, Self::Contents | Self::Both)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SearchSource {
    Worktree,
    Index,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SearchFile {
    pub path: PathBuf,
    pub source: SearchSource,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SearchTarget {
    Changes {
        files: Vec<SearchFile>,
    },
    History {
        revision: String,
        skip: usize,
        limit: usize,
        selected: Option<String>,
    },
    PullRequest {
        workspace: u64,
        paths: Vec<PathBuf>,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SearchRequest {
    pub query: String,
    pub mode: SearchMode,
    pub target: SearchTarget,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SearchHits {
    pub mode: SearchMode,
    pub query: String,
    pub paths: Vec<String>,
    pub commits: Vec<String>,
}

impl SearchHits {
    pub(crate) fn empty(mode: SearchMode, query: impl Into<String>) -> Self {
        Self {
            mode,
            query: query.into(),
            paths: Vec::new(),
            commits: Vec::new(),
        }
    }

    pub(crate) fn with_paths(mut self, mut paths: Vec<String>) -> Self {
        paths.sort();
        paths.dedup();
        self.paths = paths;
        self
    }

    pub(crate) fn with_commits(mut self, mut commits: Vec<String>) -> Self {
        commits.sort();
        commits.dedup();
        self.commits = commits;
        self
    }
}

#[derive(Debug)]
pub(crate) struct PreparedSearch {
    normalized_query: String,
    contents: Option<PreparedContents>,
}

#[derive(Debug)]
struct PreparedContents {
    matcher: RegexMatcher,
    searcher: Searcher,
}

impl PreparedSearch {
    pub(crate) fn new(query: &str, mode: SearchMode) -> Self {
        let contents = if mode.includes_contents() && !query.is_empty() {
            matcher_for(query).map(|matcher| PreparedContents {
                matcher,
                searcher: SearcherBuilder::new()
                    .binary_detection(BinaryDetection::quit(0))
                    .build(),
            })
        } else {
            None
        };
        Self {
            normalized_query: query.to_lowercase(),
            contents,
        }
    }

    pub(crate) const fn normalized_query(&self) -> &str {
        self.normalized_query.as_str()
    }

    pub(crate) fn name_matches(&self, text: &str) -> bool {
        self.normalized_query.is_empty() || text.to_lowercase().contains(&self.normalized_query)
    }

    pub(crate) fn haystack_matches(&mut self, haystack: &[u8]) -> bool {
        if self.normalized_query.is_empty() {
            return true;
        }
        let Some(contents) = self.contents.as_mut() else {
            return false;
        };
        let mut found = false;
        drop(contents.searcher.search_slice(
            &contents.matcher,
            haystack,
            Bytes(|_, _| {
                found = true;
                Ok(false)
            }),
        ));
        found
    }
}

pub(crate) fn name_matches(query: &str, text: &str) -> bool {
    if query.is_empty() {
        return true;
    }
    text.to_lowercase().contains(&query.to_lowercase())
}

fn matcher_for(query: &str) -> Option<RegexMatcher> {
    RegexMatcherBuilder::new()
        .case_insensitive(true)
        .build(query)
        .ok()
        .or_else(|| {
            RegexMatcherBuilder::new()
                .case_insensitive(true)
                .fixed_strings(true)
                .build(query)
                .ok()
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn name_mode_stays_a_case_insensitive_substring() {
        assert!(name_matches("read", "README.md"));
        assert!(name_matches("READ", "src/readme.txt"));
        assert!(!name_matches("read", "src/main.rs"));
        assert!(name_matches("", "anything"));
    }

    #[test]
    fn contents_uses_ripgrep_regex_and_falls_back_to_literals() {
        let haystack_matches = |query: &str, haystack: &[u8]| {
            PreparedSearch::new(query, SearchMode::Contents).haystack_matches(haystack)
        };
        assert!(haystack_matches("foo", b"hello foo bar"));
        assert!(haystack_matches("FOO", b"hello foo bar"));
        assert!(haystack_matches("f.o", b"fao"));
        assert!(!haystack_matches("zzz", b"hello foo"));
        assert!(haystack_matches("(", b"value (ok)"));
        assert!(haystack_matches("", b"anything"));
        assert!(haystack_matches("école", "ÉCOLE".as_bytes()));
        assert!(haystack_matches("^foo$", b"before\nFOO\nafter\n"));
        assert!(!haystack_matches("^foo$", b"before-FOO-after\n"));
    }

    #[test]
    fn prepared_search_resets_between_documents() {
        let mut early_match_utf16 = vec![0xff, 0xfe];
        early_match_utf16.extend("needle\n".encode_utf16().flat_map(u16::to_le_bytes));
        early_match_utf16.resize(512 * 1024, b' ');
        let documents: &[(&[u8], bool)] = &[
            (b"", false),
            (b"no match\n", false),
            (b"before\nNEEDLE\nafter\n", true),
            (b"", false),
            (b"\xef\xbb\xbfNEEDLE\n", true),
            (b"\xff\xfen\0e\0e\0d\0l\0e\0\n\0", true),
            (b"needle\0needle\n", false),
            (b"no match\n", false),
            (b"\xfe\xff\0N\0E\0E\0D\0L\0E\0\n", true),
            (b"\0needle\n", false),
            (&early_match_utf16, true),
            (b"\xff\xfen\0o\0\n\0", false),
            (b"no match\n", false),
            (b"needle\n", true),
            (b"", false),
        ];
        for query in ["needle", "^needle$"] {
            let mut search = PreparedSearch::new(query, SearchMode::Contents);
            for _ in 0..2 {
                for (index, &(document, expected)) in documents.iter().enumerate() {
                    let fresh =
                        PreparedSearch::new(query, SearchMode::Contents).haystack_matches(document);
                    let reused = search.haystack_matches(document);
                    assert_eq!(reused, fresh, "{query:?}: document {index}");
                    assert_eq!(reused, expected, "{query:?}: document {index}");
                }
            }
        }
    }

    #[test]
    fn name_only_and_empty_queries_skip_contents_preparation() {
        let search = PreparedSearch::new("(READ", SearchMode::Name);
        assert!(search.contents.is_none(), "{search:?}");
        assert!(search.name_matches("src/(read).txt"));
        assert!(!search.name_matches("README.md"));
        for mode in SearchMode::ALL {
            let mut search = PreparedSearch::new("", mode);
            assert!(search.contents.is_none(), "{search:?}");
            for document in [b"".as_slice(), b"\0binary", b"\xff\xfe"] {
                assert!(search.haystack_matches(document), "{mode:?}: {document:?}");
            }
        }
    }

    #[test]
    fn search_mode_cycles_through_the_three_labels() {
        assert_eq!(SearchMode::Name.cycle(), SearchMode::Contents);
        assert_eq!(SearchMode::Contents.cycle(), SearchMode::Both);
        assert_eq!(SearchMode::Both.cycle(), SearchMode::Name);
        assert_eq!(SearchMode::Name.previous(), SearchMode::Both);
        assert_eq!(SearchMode::Contents.previous(), SearchMode::Name);
        assert_eq!(SearchMode::Both.previous(), SearchMode::Contents);
        assert_eq!(
            SearchMode::ALL.map(SearchMode::label),
            ["Name", "Contents", "Both"]
        );
    }
}