Skip to main content

binocular/search/sources/git/
scope.rs

1use anyhow::{bail, Context};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum GitSearchMode {
7    History { file: PathBuf },
8    Branches,
9    Commits,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct GitSearchScope {
14    pub repo_root: PathBuf,
15    pub mode: GitSearchMode,
16    pub display_path: Option<String>,
17}
18
19pub fn resolve_history_scope(path: &Path) -> anyhow::Result<GitSearchScope> {
20    let path = if path.as_os_str().is_empty() {
21        PathBuf::from(".")
22    } else {
23        path.to_path_buf()
24    };
25
26    let repo_root = git_repo_root(&path)?;
27    let file_path = if path.is_absolute() {
28        path.clone()
29    } else {
30        repo_root.join(&path)
31    };
32    let relative = file_path
33        .strip_prefix(&repo_root)
34        .unwrap_or(&file_path)
35        .to_path_buf();
36    let display_path = relative.to_string_lossy().replace('\\', "/");
37
38    Ok(GitSearchScope {
39        repo_root,
40        mode: GitSearchMode::History { file: relative },
41        display_path: Some(display_path),
42    })
43}
44
45pub fn resolve_repo_scope(
46    start: Option<&Path>,
47    mode: GitSearchMode,
48) -> anyhow::Result<GitSearchScope> {
49    let start = start.unwrap_or_else(|| Path::new("."));
50    let repo_root = git_repo_root(start)?;
51    Ok(GitSearchScope {
52        repo_root,
53        mode,
54        display_path: None,
55    })
56}
57
58pub(crate) fn git_repo_root(path: &Path) -> anyhow::Result<PathBuf> {
59    let cwd = repo_lookup_dir(path);
60    let output = Command::new("git")
61        .arg("rev-parse")
62        .arg("--show-toplevel")
63        .current_dir(cwd)
64        .output()
65        .context("failed to determine git repository root")?;
66
67    if !output.status.success() {
68        let stderr = String::from_utf8_lossy(&output.stderr);
69        bail!(
70            "git rev-parse --show-toplevel failed: {}",
71            if_empty_then(stderr.trim(), "not a git repository")
72        );
73    }
74
75    Ok(PathBuf::from(
76        String::from_utf8_lossy(&output.stdout).trim(),
77    ))
78}
79
80fn repo_lookup_dir(path: &Path) -> PathBuf {
81    if path.is_dir() {
82        return path.to_path_buf();
83    }
84
85    match path.parent() {
86        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
87        _ => PathBuf::from("."),
88    }
89}
90
91pub(crate) fn if_empty_then<'a>(value: &'a str, fallback: &'a str) -> &'a str {
92    if value.is_empty() {
93        fallback
94    } else {
95        value
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn bare_filename_repo_lookup_uses_current_directory() {
105        assert_eq!(
106            repo_lookup_dir(Path::new("Architecture.md")),
107            PathBuf::from(".")
108        );
109        assert_eq!(
110            repo_lookup_dir(Path::new("./Architecture.md")),
111            PathBuf::from(".")
112        );
113    }
114}