binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use super::preview::read_history_blob;
use super::scope::{GitSearchMode, GitSearchScope};
use crate::infra::channel::{BatchSender, Sender};
use crate::search::types::SearchItem;
use anyhow::Context;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

const GIT_BATCH_SIZE: usize = 128;
pub const HISTORY_PATH_SEPARATOR: char = '\u{1f}';
const HISTORY_PATH_REPLACEMENT: char = '\u{fffd}';
pub const CURRENT_COMMIT_REF: &str = "HEAD";
const FIELD_SEPARATOR: char = '\u{1e}';

pub fn spawn_git_searcher(
    scope: GitSearchScope,
    stop: Arc<AtomicBool>,
    tx: impl Sender<Vec<SearchItem>>,
) -> std::thread::JoinHandle<()> {
    std::thread::spawn(move || {
        if let Err(err) = stream_git_items(scope, stop, tx.clone()) {
            let _ = tx.send(vec![SearchItem::message(err.to_string())]);
        }
    })
}

pub fn sanitize_path_field(path: &str) -> String {
    path.replace(
        HISTORY_PATH_SEPARATOR,
        &HISTORY_PATH_REPLACEMENT.to_string(),
    )
}

pub fn is_current_commit(commit: &str) -> bool {
    commit == CURRENT_COMMIT_REF
}

fn stream_git_items(
    scope: GitSearchScope,
    stop: Arc<AtomicBool>,
    tx: impl Sender<Vec<SearchItem>>,
) -> anyhow::Result<()> {
    match scope.mode {
        GitSearchMode::History { file } => {
            let display_path = scope
                .display_path
                .unwrap_or_else(|| file.to_string_lossy().into_owned());
            stream_history(scope.repo_root, file, display_path, stop, tx)
        }
        GitSearchMode::Branches => stream_branches(scope.repo_root, stop, tx),
        GitSearchMode::Commits => stream_commits(scope.repo_root, stop, tx),
    }
}

fn stream_history(
    repo_root: PathBuf,
    file: PathBuf,
    display_path: String,
    stop: Arc<AtomicBool>,
    tx: impl Sender<Vec<SearchItem>>,
) -> anyhow::Result<()> {
    let mut child = Command::new("git")
        .arg("log")
        .arg("--format=%H")
        .arg("--follow")
        .arg("--")
        .arg(&file)
        .current_dir(&repo_root)
        .stdout(Stdio::piped())
        .spawn()
        .context("failed to run git log for history search")?;

    let stdout = child.stdout.take().context("git log stdout unavailable")?;
    let mut batcher = BatchSender::new(tx, GIT_BATCH_SIZE);

    for line in BufReader::new(stdout).lines() {
        if stop.load(Ordering::Relaxed) {
            let _ = child.kill();
            let _ = child.wait();
            return Ok(());
        }

        let hash = line.context("failed to read git log output")?;
        let hash = hash.trim();
        if hash.is_empty() {
            continue;
        }

        let blob = match read_history_blob(&repo_root, hash, &file) {
            Ok(blob) => blob,
            Err(_) => continue,
        };
        if blob.contains(&0) {
            continue;
        }

        push_history_lines(&mut batcher, hash, &display_path, &blob, &stop);
    }

    wait_for_success(child, stop, "git log")
}

fn stream_branches(
    repo_root: PathBuf,
    stop: Arc<AtomicBool>,
    tx: impl Sender<Vec<SearchItem>>,
) -> anyhow::Result<()> {
    let format = format!(
        "%(HEAD){sep}%(refname:short){sep}%(objectname:short){sep}%(contents:subject){sep}%(committerdate:relative)",
        sep = FIELD_SEPARATOR
    );
    let mut child = Command::new("git")
        .arg("for-each-ref")
        .arg("--sort=-committerdate")
        .arg(format!("--format={format}"))
        .arg("refs/heads")
        .current_dir(&repo_root)
        .stdout(Stdio::piped())
        .spawn()
        .context("failed to run git for-each-ref for branches")?;

    let stdout = child
        .stdout
        .take()
        .context("git branch stdout unavailable")?;
    let mut batcher = BatchSender::new(tx, GIT_BATCH_SIZE);
    for line in BufReader::new(stdout).lines() {
        if stop.load(Ordering::Relaxed) {
            let _ = child.kill();
            let _ = child.wait();
            return Ok(());
        }

        let line = line.context("failed to read git branch output")?;
        let mut parts = line.splitn(5, FIELD_SEPARATOR);
        let head_marker = parts.next().unwrap_or_default().trim();
        let branch = parts.next().unwrap_or_default().trim();
        let commit = parts.next().unwrap_or_default().trim();
        let subject = parts.next().unwrap_or_default().trim();
        let relative_time = parts.next().unwrap_or_default().trim();
        if branch.is_empty() {
            continue;
        }
        batcher.push(SearchItem::git_branch(
            branch,
            commit,
            subject,
            head_marker == "*",
            relative_time,
        ));
    }

    wait_for_success(child, stop, "git for-each-ref")
}

fn stream_commits(
    repo_root: PathBuf,
    stop: Arc<AtomicBool>,
    tx: impl Sender<Vec<SearchItem>>,
) -> anyhow::Result<()> {
    let format = format!(
        "%H{sep}%h{sep}%s{sep}%an{sep}%ar{sep}%D",
        sep = FIELD_SEPARATOR
    );
    let mut child = Command::new("git")
        .arg("log")
        .arg("--date=short")
        .arg(format!("--format={format}"))
        .current_dir(&repo_root)
        .stdout(Stdio::piped())
        .spawn()
        .context("failed to run git log for commits")?;

    let stdout = child
        .stdout
        .take()
        .context("git commit log stdout unavailable")?;
    let mut batcher = BatchSender::new(tx, GIT_BATCH_SIZE);
    for line in BufReader::new(stdout).lines() {
        if stop.load(Ordering::Relaxed) {
            let _ = child.kill();
            let _ = child.wait();
            return Ok(());
        }

        let line = line.context("failed to read git commit output")?;
        let mut parts = line.splitn(6, FIELD_SEPARATOR);
        let commit = parts.next().unwrap_or_default().trim();
        let short_commit = parts.next().unwrap_or_default().trim();
        let subject = parts.next().unwrap_or_default().trim();
        let author = parts.next().unwrap_or_default().trim();
        let date = parts.next().unwrap_or_default().trim();
        let refs = parts.next().unwrap_or_default().trim();
        if commit.is_empty() {
            continue;
        }
        batcher.push(SearchItem::git_commit(
            commit,
            short_commit,
            subject,
            author,
            date,
            refs,
        ));
    }

    wait_for_success(child, stop, "git log")
}

fn push_history_lines<S: Sender<Vec<SearchItem>>>(
    batcher: &mut BatchSender<SearchItem, S>,
    commit: &str,
    display_path: &str,
    blob: &[u8],
    stop: &AtomicBool,
) {
    let sanitized_path = sanitize_path_field(display_path);
    let mut line_number = 1usize;
    for line in BufReader::new(blob).split(b'\n') {
        if stop.load(Ordering::Relaxed) {
            break;
        }

        let Ok(mut line) = line else { break };
        if line.last() == Some(&b'\r') {
            line.pop();
        }
        let text = String::from_utf8_lossy(&line).into_owned();
        batcher.push(SearchItem::history_line(
            commit,
            &sanitized_path,
            line_number,
            text,
        ));
        line_number += 1;
    }
}

fn wait_for_success(
    mut child: std::process::Child,
    stop: Arc<AtomicBool>,
    command_name: &str,
) -> anyhow::Result<()> {
    let status = child
        .wait()
        .with_context(|| format!("failed to wait for {command_name}"))?;
    if !status.success() && !stop.load(Ordering::Relaxed) {
        anyhow::bail!("{command_name} exited with status {status}");
    }
    Ok(())
}

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

    #[test]
    fn sanitize_path_field_replaces_internal_separator() {
        let sanitized = sanitize_path_field("dir\u{1f}file.txt");
        assert!(!sanitized.contains(HISTORY_PATH_SEPARATOR));
    }
}