binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use anyhow::{bail, Context};
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitSearchMode {
    History { file: PathBuf },
    Branches,
    Commits,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitSearchScope {
    pub repo_root: PathBuf,
    pub mode: GitSearchMode,
    pub display_path: Option<String>,
}

pub fn resolve_history_scope(path: &Path) -> anyhow::Result<GitSearchScope> {
    let path = if path.as_os_str().is_empty() {
        PathBuf::from(".")
    } else {
        path.to_path_buf()
    };

    let repo_root = git_repo_root(&path)?;
    let file_path = if path.is_absolute() {
        path.clone()
    } else {
        repo_root.join(&path)
    };
    let relative = file_path
        .strip_prefix(&repo_root)
        .unwrap_or(&file_path)
        .to_path_buf();
    let display_path = relative.to_string_lossy().replace('\\', "/");

    Ok(GitSearchScope {
        repo_root,
        mode: GitSearchMode::History { file: relative },
        display_path: Some(display_path),
    })
}

pub fn resolve_repo_scope(
    start: Option<&Path>,
    mode: GitSearchMode,
) -> anyhow::Result<GitSearchScope> {
    let start = start.unwrap_or_else(|| Path::new("."));
    let repo_root = git_repo_root(start)?;
    Ok(GitSearchScope {
        repo_root,
        mode,
        display_path: None,
    })
}

pub(crate) fn git_repo_root(path: &Path) -> anyhow::Result<PathBuf> {
    let cwd = repo_lookup_dir(path);
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--show-toplevel")
        .current_dir(cwd)
        .output()
        .context("failed to determine git repository root")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!(
            "git rev-parse --show-toplevel failed: {}",
            if_empty_then(stderr.trim(), "not a git repository")
        );
    }

    Ok(PathBuf::from(
        String::from_utf8_lossy(&output.stdout).trim(),
    ))
}

fn repo_lookup_dir(path: &Path) -> PathBuf {
    if path.is_dir() {
        return path.to_path_buf();
    }

    match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
        _ => PathBuf::from("."),
    }
}

pub(crate) fn if_empty_then<'a>(value: &'a str, fallback: &'a str) -> &'a str {
    if value.is_empty() {
        fallback
    } else {
        value
    }
}

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

    #[test]
    fn bare_filename_repo_lookup_uses_current_directory() {
        assert_eq!(
            repo_lookup_dir(Path::new("Architecture.md")),
            PathBuf::from(".")
        );
        assert_eq!(
            repo_lookup_dir(Path::new("./Architecture.md")),
            PathBuf::from(".")
        );
    }
}