quvyta-cli 0.1.0-alpha.4

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
//! `search`: an exact piece of text in the text files under a folder, narrowed by name and case.

use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};

use super::workspace::Workspace;

/// At most this many matches are returned.
pub(super) const MAX_MATCHES: usize = 200;
/// Folders that hold history, build output or downloaded code, not the project's own text.
const SKIPPED: [&str; 3] = [".git", "target", "node_modules"];
/// A NUL byte in this many first bytes marks a file as binary.
const BINARY_PROBE: usize = 8192;
/// Files larger than this are not searched; they are data, not source.
const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024;
/// A matching line longer than this is shortened in the result.
const MAX_LINE_CHARS: usize = 300;
/// How long a search may walk before it stops with what it found. A model that searches a home
/// folder or a disk of data would otherwise walk it for minutes while the person waits.
pub(super) const LIMIT: Duration = Duration::from_secs(30);

struct Found<'a> {
    lines: Vec<String>,
    cut: bool,
    /// Why the walk ended before the tree did, other than the cap on matches.
    stopped: Option<Stopped>,
    /// When the walk has to stop.
    until: Instant,
    /// Asked between files: the person stopped the turn.
    cancelled: &'a dyn Fn() -> bool,
}

#[derive(Clone, Copy)]
enum Stopped {
    Time,
    Person,
}

impl Found<'_> {
    /// Whether the walk has to end here, noting why when it is the clock or the person.
    fn over(&mut self) -> bool {
        if self.cut || self.stopped.is_some() {
            return true;
        }
        if (self.cancelled)() {
            self.stopped = Some(Stopped::Person);
        } else if Instant::now() >= self.until {
            self.stopped = Some(Stopped::Time);
        }
        self.stopped.is_some()
    }
}

pub(super) fn search(
    workspace: &Workspace,
    pattern: &str,
    path: &str,
    glob: Option<&str>,
    ignore_case: bool,
    cancelled: &dyn Fn() -> bool,
    limit: Duration,
) -> Result<String, String> {
    if pattern.is_empty() {
        return Err("`search` needs a non-empty `pattern`.".to_owned());
    }
    let start = workspace.resolve(path)?;
    let shown = workspace.relative(&start);
    if !start.exists() {
        return Err(format!("`{shown}` does not exist."));
    }
    let folded = ignore_case.then(|| pattern.to_lowercase());
    let mut found = Found { lines: Vec::new(), cut: false, stopped: None, until: Instant::now() + limit, cancelled };
    visit(workspace, &start, pattern, folded.as_deref(), glob, &mut found);
    let stopped = found.stopped.map(|why| match why {
        Stopped::Time => format!(
            "(Stopped after {} s with part of `{shown}` searched; narrow the search with `path` or a `glob` such as `*.rs`.)",
            limit.as_secs()
        ),
        Stopped::Person => "(Stopped by the person before the search was done.)".to_owned(),
    });
    if found.lines.is_empty() {
        return Ok(match stopped {
            Some(stopped) => format!("No matches for `{pattern}` in the part searched. {stopped}"),
            None => nothing_found(pattern, &shown, glob),
        });
    }
    let mut out = found.lines.join("\n");
    if let Some(stopped) = stopped {
        out.push('\n');
        out.push_str(&stopped);
    }
    if found.cut {
        out.push_str(&format!(
            "\n(Stopped at {MAX_MATCHES} matches; narrow the search with `path`, a `glob` such as `*.rs` or a longer pattern.)"
        ));
    }
    Ok(out)
}

/// The sentence for a search that found nothing: it names the two ways to try again, so the model
/// does not send the same search once more.
fn nothing_found(pattern: &str, shown: &str, glob: Option<&str>) -> String {
    let mut out = format!("No matches for `{pattern}` in `{shown}`.");
    out.push_str(" Try `ignore_case` for the same text in another case");
    if glob.is_none() {
        out.push_str("; or a `glob` such as `*.rs` to look at fewer files");
    }
    out.push('.');
    out
}

/// Walks in name order so the same tree always gives the same result. Links are not followed:
/// they could lead outside the workspace or around in a circle.
fn visit(
    workspace: &Workspace,
    path: &Path,
    pattern: &str,
    folded: Option<&str>,
    glob: Option<&str>,
    found: &mut Found<'_>,
) {
    if found.over() {
        return;
    }
    let Ok(meta) = fs::symlink_metadata(path) else {
        return;
    };
    if meta.is_file() {
        if meta.len() <= MAX_FILE_BYTES {
            search_file(workspace, path, pattern, folded, glob, found);
        }
        return;
    }
    if !meta.is_dir() {
        return;
    }
    let Ok(entries) = fs::read_dir(path) else {
        return;
    };
    let mut children: Vec<_> = entries.filter_map(Result::ok).collect();
    children.sort_by_key(std::fs::DirEntry::file_name);
    for child in children {
        let is_dir = child.file_type().is_ok_and(|kind| kind.is_dir());
        if is_dir && SKIPPED.iter().any(|name| child.file_name() == *name) {
            continue;
        }
        visit(workspace, &child.path(), pattern, folded, glob, found);
        if found.over() {
            return;
        }
    }
}

fn search_file(
    workspace: &Workspace,
    path: &Path,
    pattern: &str,
    folded: Option<&str>,
    glob: Option<&str>,
    found: &mut Found<'_>,
) {
    if let Some(glob) = glob
        && !glob_matches(glob, &workspace.relative(path))
    {
        return;
    }
    let Ok(bytes) = fs::read(path) else {
        return;
    };
    if bytes[..bytes.len().min(BINARY_PROBE)].contains(&0) {
        return;
    }
    let text = String::from_utf8_lossy(&bytes);
    let shown = workspace.relative(path);
    for (index, line) in text.lines().enumerate() {
        let line_holds = match folded {
            Some(folded) => line.to_lowercase().contains(folded),
            None => line.contains(pattern),
        };
        if !line_holds {
            continue;
        }
        if found.lines.len() == MAX_MATCHES {
            found.cut = true;
            return;
        }
        let number = index + 1;
        let line = shorten(line);
        found.lines.push(format!("{shown}:{number}: {line}"));
    }
}

/// Whether the path of a file matches the pattern the model gave: `*` stands for any number of
/// characters, folder names included, and `?` for exactly one character. There is no other syntax,
/// so every string is a pattern that matches or does not.
pub(super) fn glob_matches(pattern: &str, path: &str) -> bool {
    let pattern: Vec<char> = pattern.chars().collect();
    let text: Vec<char> = path.chars().collect();
    let (mut at, mut over) = (0, 0);
    // Where the last `*` was and how far the text got past it, to try one more character on it.
    let mut star: Option<(usize, usize)> = None;
    while over < text.len() {
        if at < pattern.len() && (pattern[at] == text[over] || pattern[at] == '?') {
            at += 1;
            over += 1;
        } else if at < pattern.len() && pattern[at] == '*' {
            star = Some((at, over));
            at += 1;
        } else if let Some((was, from)) = star {
            at = was + 1;
            over = from + 1;
            star = Some((was, over));
        } else {
            return false;
        }
    }
    pattern[at..].iter().all(|letter| *letter == '*')
}

fn shorten(line: &str) -> String {
    match line.char_indices().nth(MAX_LINE_CHARS) {
        Some((end, _)) => format!("{}…", &line[..end]),
        None => line.to_owned(),
    }
}