quvyta-cli 0.1.0-alpha.5

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
//! `read_file`: a text file, the whole of it or one range of lines, cut when long.

use super::workspace::{READ_CAP, Workspace, io_problem, not_regular, read_capped};

/// At most this many lines are returned.
pub(super) const MAX_LINES: usize = 2000;
/// At most this many bytes are returned.
pub(super) const MAX_BYTES: usize = 100 * 1024;

/// Which lines of a file a read asks for. Both numbers count from 1 and both ends are inside the
/// range; neither one given is the top of the file, as it was before.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Lines {
    /// The first line to show, counting from 1.
    pub start: Option<i64>,
    /// The last line to show, included.
    pub end: Option<i64>,
}

impl Lines {
    /// Whether the model asked for a part of the file rather than its top.
    #[must_use]
    pub fn asked(&self) -> bool {
        self.start.is_some() || self.end.is_some()
    }
}

pub(super) fn read(workspace: &Workspace, path: &str, lines: Lines) -> Result<String, String> {
    let real = workspace.resolve(path)?;
    let shown = workspace.relative(&real);
    if real.is_dir() {
        return Err(format!("`{shown}` is a folder; use list_dir to see what is in it."));
    }
    let contents =
        read_capped(&real).map_err(|error| io_problem(&shown, &error))?.ok_or_else(|| not_regular(&shown))?;
    let (whole, size) = (contents.whole(), contents.size);
    let mut bytes = contents.bytes;
    if !whole && let Some(end) = bytes.iter().rposition(|byte| *byte == b'\n') {
        // Only whole lines of the part read, so no character is cut in half.
        bytes.truncate(end + 1);
    }
    let text = match String::from_utf8(bytes) {
        Ok(text) if !text.contains('\0') => text,
        _ => {
            return Err(format!("`{shown}` is not a text file ({size} bytes); it cannot be shown."));
        }
    };
    if text.is_empty() {
        return Ok(format!("`{shown}` is empty."));
    }
    let shown_part = if lines.asked() { part(&text, lines) } else { Ok(head(&text)) };
    if whole {
        return shown_part;
    }
    let cap = format!(
        "(The file is {size} bytes and only its first {} MB are read, so the lines counted here are of that part.)",
        READ_CAP / (1024 * 1024)
    );
    match shown_part {
        Ok(text) => Ok(join(&text, &cap)),
        Err(sentence) => Err(format!("{sentence} {cap}")),
    }
}

/// The first lines of the file, cut when it is long: what a read without a range has always given.
fn head(text: &str) -> String {
    let mut end = 0;
    let mut lines = 0;
    for line in text.split_inclusive('\n') {
        if lines == MAX_LINES || end + line.len() > MAX_BYTES {
            break;
        }
        end += line.len();
        lines += 1;
    }
    if end == text.len() {
        return text.to_owned();
    }
    let total = text.lines().count();
    let note = if lines == 0 {
        // The first line alone is over the byte cap: show its start rather than nothing.
        end = text.floor_char_boundary(MAX_BYTES);
        format!(
            "(Cut: the file has {total} lines and the first one alone is longer than {} KB; only its start is shown.)",
            MAX_BYTES / 1024
        )
    } else {
        format!("(Cut: the file has {total} lines; only the first {lines} are shown.)")
    };
    join(&text[..end], &note)
}

/// The lines the model asked for, under a note that says which lines they are and what is left in
/// the file. The file is already in hand, so nothing else is read from disk.
fn part(text: &str, lines: Lines) -> Result<String, String> {
    let total = text.lines().count();
    let (first, last) = bounds(lines, total)?;
    let mut out = String::new();
    let mut first_shown = 0;
    let mut last_shown = 0;
    let mut shown = 0;
    let mut cut: Option<String> = None;
    let mut offset = 0;
    for (index, line) in text.split_inclusive('\n').enumerate() {
        let number = index + 1;
        if number < first {
            offset += line.len();
            continue;
        }
        if number > last {
            break;
        }
        if shown == MAX_LINES {
            cut = Some(format!("The {MAX_LINES}-line cap stopped it there."));
            break;
        }
        if out.len() + line.len() > MAX_BYTES {
            cut = Some(format!("The {} KB cap stopped it there.", MAX_BYTES / 1024));
            break;
        }
        if first_shown == 0 {
            first_shown = number;
        }
        out.push_str(line);
        last_shown = number;
        shown += 1;
    }
    if first_shown == 0 {
        // The first line of the range alone is over the byte cap: show its start rather than
        // nothing, and say that it is not the whole line.
        let rest = &text[offset..];
        out.push_str(&rest[..rest.floor_char_boundary(MAX_BYTES)]);
        first_shown = first;
        last_shown = first;
        cut = Some(format!("That one line is longer than {} KB and only its start is shown.", MAX_BYTES / 1024));
    }
    let note = note(first_shown, last_shown, total, cut.as_deref());
    Ok(join(&out, &note))
}

/// The first and last line to show, held inside the file, or a sentence saying why the range
/// cannot be used. Nothing is read when it cannot.
fn bounds(lines: Lines, total: usize) -> Result<(usize, usize), String> {
    for (field, number) in [("start_line", lines.start), ("end_line", lines.end)] {
        if number.is_some_and(|number| number < 1) {
            return Err(format!("Lines are counted from 1, so `{field}` must be 1 or more."));
        }
    }
    let first = usize::try_from(lines.start.unwrap_or(1)).unwrap_or(usize::MAX);
    let last = match lines.end {
        Some(end) => {
            let end = usize::try_from(end).unwrap_or(usize::MAX);
            if end < first {
                return Err("`end_line` is before `start_line`; give a range that starts before it ends.".to_owned());
            }
            // An end past the last line is not a mistake to refuse over: the note below names the
            // last line that is really there.
            end.min(total)
        }
        None => total,
    };
    if first > total {
        return Err(format!("The file has {total} lines, so `start_line` {first} is past its end."));
    }
    Ok((first, last))
}

/// The sentence that says which lines are in the answer, what a cap did to it and how much of the
/// file the model did not get.
fn note(first: usize, last: usize, total: usize, cut: Option<&str>) -> String {
    let above = first.saturating_sub(1);
    let below = total.saturating_sub(last);
    let left = match (above > 0, below > 0) {
        (true, true) => format!("{} above and {} below", count(above), count(below)),
        (true, false) => format!("{} above", count(above)),
        (false, true) => format!("{} below", count(below)),
        (false, false) => String::new(),
    };
    let mut note = if left.is_empty() {
        format!("(The whole file: {}.", count(total))
    } else {
        format!("(Lines {first}-{last} of {total}.")
    };
    if let Some(cut) = cut {
        note.push(' ');
        note.push_str(cut);
    }
    if !left.is_empty() {
        note.push_str(&format!(" Not shown: {left}."));
    }
    note.push(')');
    note
}

/// A number of lines, with the word in the number's own singular or plural.
fn count(lines: usize) -> String {
    format!("{lines} line{}", if lines == 1 { "" } else { "s" })
}

/// The lines and the note under them, a line ending between the two.
fn join(lines: &str, note: &str) -> String {
    let mut out = lines.to_owned();
    if !out.is_empty() && !out.ends_with('\n') {
        out.push('\n');
    }
    out.push_str(note);
    out
}