parley-cli 0.3.0

Terminal-first review tool for AI-generated code changes
Documentation
use super::*;
use crate::git::diff::{DiffSource, load_root_directory_file};
use anyhow::{Context, Result};
use std::io::ErrorKind;
use std::path::Path;
use tokio::process::Command;

const CODE_SEARCH_MAX_RESULTS: usize = 200;
const GREP_FILE_CHUNK_SIZE: usize = 200;

#[derive(Debug)]
struct CodeSearchRun {
    engine: &'static str,
    results: Vec<CodeSearchResult>,
}

impl TuiApp {
    pub(super) async fn open_code_search(&mut self) -> Result<()> {
        self.dismiss_ai_progress_popup();
        self.code_search = Some(CodeSearchState {
            query: String::new(),
            cursor_col: 0,
            results: Vec::new(),
            selected_index: 0,
            scroll: 0,
            engine: None,
            message: "type to search with rg; grep fallback if rg is unavailable".into(),
        });
        self.status_line = "code search opened".into();
        Ok(())
    }

    pub(super) async fn handle_code_search_key(&mut self, key: KeyEvent) -> Result<()> {
        if matches!(key.code, KeyCode::Esc) {
            self.code_search = None;
            self.status_line = "code search closed".into();
            return Ok(());
        }

        if matches!(key.code, KeyCode::Enter) {
            return self.open_selected_code_search_result().await;
        }

        let mut should_refresh = false;
        if let Some(search) = self.code_search.as_mut() {
            match key.code {
                KeyCode::Up => {
                    search.selected_index = search.selected_index.saturating_sub(1);
                }
                KeyCode::Down => {
                    let max_index = search.results.len().saturating_sub(1);
                    search.selected_index = (search.selected_index + 1).min(max_index);
                }
                KeyCode::PageUp => {
                    search.selected_index = search.selected_index.saturating_sub(8);
                }
                KeyCode::PageDown => {
                    let max_index = search.results.len().saturating_sub(1);
                    search.selected_index = (search.selected_index + 8).min(max_index);
                }
                KeyCode::Home => {
                    search.cursor_col = 0;
                }
                KeyCode::End => {
                    search.cursor_col = search.query.chars().count();
                }
                KeyCode::Left => {
                    search.cursor_col = search.cursor_col.saturating_sub(1);
                }
                KeyCode::Right => {
                    search.cursor_col = (search.cursor_col + 1).min(search.query.chars().count());
                }
                KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    search.cursor_col = 0;
                }
                KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    search.cursor_col = search.query.chars().count();
                }
                KeyCode::Backspace if search.cursor_col > 0 => {
                    remove_char_at(&mut search.query, search.cursor_col - 1);
                    search.cursor_col -= 1;
                    should_refresh = true;
                }
                KeyCode::Delete if search.cursor_col < search.query.chars().count() => {
                    remove_char_at(&mut search.query, search.cursor_col);
                    should_refresh = true;
                }
                KeyCode::Char(ch)
                    if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
                {
                    insert_char_at(&mut search.query, search.cursor_col, ch);
                    search.cursor_col += 1;
                    should_refresh = true;
                }
                _ => {}
            }
        }

        if should_refresh {
            self.refresh_code_search().await?;
        }
        self.constrain_code_search_selection();
        Ok(())
    }

    async fn refresh_code_search(&mut self) -> Result<()> {
        let Some(search) = self.code_search.as_mut() else {
            return Ok(());
        };
        let query = search.query.trim().to_string();
        search.selected_index = 0;
        search.scroll = 0;
        if query.is_empty() {
            search.results.clear();
            search.engine = None;
            search.message = "type to search with rg; grep fallback if rg is unavailable".into();
            self.search_query = None;
            self.status_line = "code search ready".into();
            return Ok(());
        }

        search.message = format!("searching for {query}");
        self.search_query = Some(query.clone());
        let run = run_code_search(&query, &self.worktree_path).await?;
        let result_count = run.results.len();
        search.results = run.results;
        search.engine = Some(run.engine);
        search.message = if result_count == 0 {
            format!("no matches via {}", run.engine)
        } else if result_count >= CODE_SEARCH_MAX_RESULTS {
            format!(
                "showing first {CODE_SEARCH_MAX_RESULTS} matches via {}",
                run.engine
            )
        } else {
            format!("{result_count} match(es) via {}", run.engine)
        };
        self.status_line = search.message.clone();
        Ok(())
    }

    pub(super) fn constrain_code_search_selection(&mut self) {
        let Some(search) = self.code_search.as_mut() else {
            return;
        };
        if search.results.is_empty() {
            search.selected_index = 0;
            search.scroll = 0;
            return;
        }
        search.selected_index = search
            .selected_index
            .min(search.results.len().saturating_sub(1));
        if search.selected_index < search.scroll {
            search.scroll = search.selected_index;
        } else if search.selected_index >= search.scroll.saturating_add(10) {
            search.scroll = search.selected_index.saturating_sub(9);
        }
    }

    pub(super) async fn open_selected_code_search_result(&mut self) -> Result<()> {
        let Some(selected_index) = self
            .code_search
            .as_ref()
            .map(|search| search.selected_index)
        else {
            self.status_line = "no code search result selected".into();
            return Ok(());
        };
        self.open_code_search_result_at_index(selected_index).await
    }

    pub(super) async fn open_code_search_result_at_index(
        &mut self,
        result_index: usize,
    ) -> Result<()> {
        let Some(result) = self
            .code_search
            .as_ref()
            .and_then(|search| search.results.get(result_index))
            .cloned()
        else {
            self.status_line = "no code search result selected".into();
            return Ok(());
        };

        let file_index = if let Some(index) = self
            .diff
            .files
            .iter()
            .position(|file| file.path == result.path)
        {
            index
        } else if let Some(file) =
            load_root_directory_file(&self.config, result.path.clone(), &self.worktree_path)
                .await
                .with_context(|| format!("failed to load {}", result.path))?
        {
            self.diff.files.push(file);
            self.invalidate_visible_file_indices_cache();
            let index = self.diff.files.len().saturating_sub(1);
            self.root_hydrated_files.insert(index);
            index
        } else {
            self.status_line = format!("search result is not reviewable: {}", result.path);
            return Ok(());
        };

        self.hydrate_code_search_file_if_needed(file_index, &result.path)
            .await?;
        self.select_file(file_index);
        self.ensure_row_cache_for_file(file_index);
        let opened = self.goto_line_number(result.line);
        self.code_search = None;
        self.status_line = if opened {
            format!("opened {}:{}", result.path, result.line)
        } else {
            format!("opened {} (line {} not in view)", result.path, result.line)
        };
        Ok(())
    }

    async fn hydrate_code_search_file_if_needed(
        &mut self,
        file_index: usize,
        path: &str,
    ) -> Result<()> {
        if !matches!(self.diff_source, DiffSource::RootDirectory)
            || self.root_hydrated_files.contains(&file_index)
            || self
                .diff
                .files
                .get(file_index)
                .is_some_and(|file| !file.hunks.is_empty())
        {
            return Ok(());
        }

        if let Some(file) =
            load_root_directory_file(&self.config, path.to_string(), &self.worktree_path)
                .await
                .with_context(|| format!("failed to load {path}"))?
            && let Some(slot) = self.diff.files.get_mut(file_index)
        {
            *slot = file;
            self.root_hydrated_files.insert(file_index);
            self.row_cache.remove(&file_index);
            self.clear_diff_render_cache_for_file(file_index);
        }
        Ok(())
    }
}

async fn run_code_search(query: &str, worktree_path: &Path) -> Result<CodeSearchRun> {
    match run_rg_search(query, worktree_path).await {
        Ok(results) => Ok(CodeSearchRun {
            engine: "rg",
            results,
        }),
        Err(error)
            if error
                .downcast_ref::<std::io::Error>()
                .is_some_and(|io_error| io_error.kind() == ErrorKind::NotFound) =>
        {
            Ok(CodeSearchRun {
                engine: "grep",
                results: run_grep_search(query, worktree_path).await?,
            })
        }
        Err(error) => Err(error),
    }
}

async fn run_rg_search(query: &str, worktree_path: &Path) -> Result<Vec<CodeSearchResult>> {
    let output = Command::new("rg")
        .current_dir(worktree_path)
        .args([
            "--line-number",
            "--column",
            "--color",
            "never",
            "--smart-case",
            "--glob",
            "!worktrees/**",
            "--",
            query,
        ])
        .output()
        .await?;

    if !output.status.success() && output.status.code() != Some(1) {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("rg search failed: {}", stderr.trim());
    }

    Ok(parse_rg_output(&String::from_utf8_lossy(&output.stdout)))
}

async fn run_grep_search(query: &str, worktree_path: &Path) -> Result<Vec<CodeSearchResult>> {
    let files_output = Command::new("git")
        .current_dir(worktree_path)
        .args(["ls-files", "--cached", "--others", "--exclude-standard"])
        .output()
        .await
        .context("failed to list git-tracked and unignored files")?;
    if !files_output.status.success() {
        let stderr = String::from_utf8_lossy(&files_output.stderr);
        anyhow::bail!("git ls-files failed for grep fallback: {}", stderr.trim());
    }

    let files = String::from_utf8_lossy(&files_output.stdout)
        .lines()
        .filter(|path| !path.starts_with("worktrees/"))
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();
    let mut results = Vec::new();
    for chunk in files.chunks(GREP_FILE_CHUNK_SIZE) {
        if results.len() >= CODE_SEARCH_MAX_RESULTS {
            break;
        }
        let output = Command::new("grep")
            .current_dir(worktree_path)
            .args(["-nI", "-e", query, "--"])
            .args(chunk)
            .output()
            .await?;
        if !output.status.success() && output.status.code() != Some(1) {
            let stderr = String::from_utf8_lossy(&output.stderr);
            anyhow::bail!("grep fallback failed: {}", stderr.trim());
        }
        results.extend(parse_grep_output(&String::from_utf8_lossy(&output.stdout)));
        results.truncate(CODE_SEARCH_MAX_RESULTS);
    }
    Ok(results)
}

fn parse_rg_output(output: &str) -> Vec<CodeSearchResult> {
    output
        .lines()
        .filter_map(parse_rg_output_line)
        .take(CODE_SEARCH_MAX_RESULTS)
        .collect()
}

fn parse_grep_output(output: &str) -> Vec<CodeSearchResult> {
    output
        .lines()
        .filter_map(parse_grep_output_line)
        .take(CODE_SEARCH_MAX_RESULTS)
        .collect()
}

fn parse_rg_output_line(line: &str) -> Option<CodeSearchResult> {
    let (path, rest) = line.split_once(':')?;
    let (line_number, rest) = rest.split_once(':')?;
    let (column, text) = rest.split_once(':')?;
    Some(CodeSearchResult {
        path: path.to_string(),
        line: line_number.parse().ok()?,
        column: column.parse().ok()?,
        text: text.to_string(),
    })
}

fn parse_grep_output_line(line: &str) -> Option<CodeSearchResult> {
    let (path, rest) = line.split_once(':')?;
    let (line_number, text) = rest.split_once(':')?;
    Some(CodeSearchResult {
        path: path.to_string(),
        line: line_number.parse().ok()?,
        column: 1,
        text: text.to_string(),
    })
}

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

    #[test]
    fn parse_rg_output_line_reads_path_line_column_and_text() {
        let parsed = parse_rg_output_line("src/main.rs:12:4:let query = search();")
            .expect("rg output should parse");

        assert_eq!(parsed.path, "src/main.rs");
        assert_eq!(parsed.line, 12);
        assert_eq!(parsed.column, 4);
        assert_eq!(parsed.text, "let query = search();");
    }

    #[test]
    fn parse_grep_output_line_defaults_column_to_one() {
        let parsed = parse_grep_output_line("src/main.rs:12:let query = search();")
            .expect("grep output should parse");

        assert_eq!(parsed.path, "src/main.rs");
        assert_eq!(parsed.line, 12);
        assert_eq!(parsed.column, 1);
        assert_eq!(parsed.text, "let query = search();");
    }
}