Skip to main content

qcli/actions/
read.rs

1//! `read_file`: a text file, the whole of it or one range of lines, cut when long.
2
3use super::workspace::{READ_CAP, Workspace, io_problem, not_regular, read_capped};
4
5/// At most this many lines are returned.
6pub(super) const MAX_LINES: usize = 2000;
7/// At most this many bytes are returned.
8pub(super) const MAX_BYTES: usize = 100 * 1024;
9
10/// Which lines of a file a read asks for. Both numbers count from 1 and both ends are inside the
11/// range; neither one given is the top of the file, as it was before.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub struct Lines {
14    /// The first line to show, counting from 1.
15    pub start: Option<i64>,
16    /// The last line to show, included.
17    pub end: Option<i64>,
18}
19
20impl Lines {
21    /// Whether the model asked for a part of the file rather than its top.
22    #[must_use]
23    pub fn asked(&self) -> bool {
24        self.start.is_some() || self.end.is_some()
25    }
26}
27
28pub(super) fn read(workspace: &Workspace, path: &str, lines: Lines) -> Result<String, String> {
29    let real = workspace.resolve(path)?;
30    let shown = workspace.relative(&real);
31    if real.is_dir() {
32        return Err(format!("`{shown}` is a folder; use list_dir to see what is in it."));
33    }
34    let contents =
35        read_capped(&real).map_err(|error| io_problem(&shown, &error))?.ok_or_else(|| not_regular(&shown))?;
36    let (whole, size) = (contents.whole(), contents.size);
37    let mut bytes = contents.bytes;
38    if !whole && let Some(end) = bytes.iter().rposition(|byte| *byte == b'\n') {
39        // Only whole lines of the part read, so no character is cut in half.
40        bytes.truncate(end + 1);
41    }
42    let text = match String::from_utf8(bytes) {
43        Ok(text) if !text.contains('\0') => text,
44        _ => {
45            return Err(format!("`{shown}` is not a text file ({size} bytes); it cannot be shown."));
46        }
47    };
48    if text.is_empty() {
49        return Ok(format!("`{shown}` is empty."));
50    }
51    let shown_part = if lines.asked() { part(&text, lines) } else { Ok(head(&text)) };
52    if whole {
53        return shown_part;
54    }
55    let cap = format!(
56        "(The file is {size} bytes and only its first {} MB are read, so the lines counted here are of that part.)",
57        READ_CAP / (1024 * 1024)
58    );
59    match shown_part {
60        Ok(text) => Ok(join(&text, &cap)),
61        Err(sentence) => Err(format!("{sentence} {cap}")),
62    }
63}
64
65/// The first lines of the file, cut when it is long: what a read without a range has always given.
66fn head(text: &str) -> String {
67    let mut end = 0;
68    let mut lines = 0;
69    for line in text.split_inclusive('\n') {
70        if lines == MAX_LINES || end + line.len() > MAX_BYTES {
71            break;
72        }
73        end += line.len();
74        lines += 1;
75    }
76    if end == text.len() {
77        return text.to_owned();
78    }
79    let total = text.lines().count();
80    let note = if lines == 0 {
81        // The first line alone is over the byte cap: show its start rather than nothing.
82        end = text.floor_char_boundary(MAX_BYTES);
83        format!(
84            "(Cut: the file has {total} lines and the first one alone is longer than {} KB; only its start is shown.)",
85            MAX_BYTES / 1024
86        )
87    } else {
88        format!("(Cut: the file has {total} lines; only the first {lines} are shown.)")
89    };
90    join(&text[..end], &note)
91}
92
93/// The lines the model asked for, under a note that says which lines they are and what is left in
94/// the file. The file is already in hand, so nothing else is read from disk.
95fn part(text: &str, lines: Lines) -> Result<String, String> {
96    let total = text.lines().count();
97    let (first, last) = bounds(lines, total)?;
98    let mut out = String::new();
99    let mut first_shown = 0;
100    let mut last_shown = 0;
101    let mut shown = 0;
102    let mut cut: Option<String> = None;
103    let mut offset = 0;
104    for (index, line) in text.split_inclusive('\n').enumerate() {
105        let number = index + 1;
106        if number < first {
107            offset += line.len();
108            continue;
109        }
110        if number > last {
111            break;
112        }
113        if shown == MAX_LINES {
114            cut = Some(format!("The {MAX_LINES}-line cap stopped it there."));
115            break;
116        }
117        if out.len() + line.len() > MAX_BYTES {
118            cut = Some(format!("The {} KB cap stopped it there.", MAX_BYTES / 1024));
119            break;
120        }
121        if first_shown == 0 {
122            first_shown = number;
123        }
124        out.push_str(line);
125        last_shown = number;
126        shown += 1;
127    }
128    if first_shown == 0 {
129        // The first line of the range alone is over the byte cap: show its start rather than
130        // nothing, and say that it is not the whole line.
131        let rest = &text[offset..];
132        out.push_str(&rest[..rest.floor_char_boundary(MAX_BYTES)]);
133        first_shown = first;
134        last_shown = first;
135        cut = Some(format!("That one line is longer than {} KB and only its start is shown.", MAX_BYTES / 1024));
136    }
137    let note = note(first_shown, last_shown, total, cut.as_deref());
138    Ok(join(&out, &note))
139}
140
141/// The first and last line to show, held inside the file, or a sentence saying why the range
142/// cannot be used. Nothing is read when it cannot.
143fn bounds(lines: Lines, total: usize) -> Result<(usize, usize), String> {
144    for (field, number) in [("start_line", lines.start), ("end_line", lines.end)] {
145        if number.is_some_and(|number| number < 1) {
146            return Err(format!("Lines are counted from 1, so `{field}` must be 1 or more."));
147        }
148    }
149    let first = usize::try_from(lines.start.unwrap_or(1)).unwrap_or(usize::MAX);
150    let last = match lines.end {
151        Some(end) => {
152            let end = usize::try_from(end).unwrap_or(usize::MAX);
153            if end < first {
154                return Err("`end_line` is before `start_line`; give a range that starts before it ends.".to_owned());
155            }
156            // An end past the last line is not a mistake to refuse over: the note below names the
157            // last line that is really there.
158            end.min(total)
159        }
160        None => total,
161    };
162    if first > total {
163        return Err(format!("The file has {total} lines, so `start_line` {first} is past its end."));
164    }
165    Ok((first, last))
166}
167
168/// The sentence that says which lines are in the answer, what a cap did to it and how much of the
169/// file the model did not get.
170fn note(first: usize, last: usize, total: usize, cut: Option<&str>) -> String {
171    let above = first.saturating_sub(1);
172    let below = total.saturating_sub(last);
173    let left = match (above > 0, below > 0) {
174        (true, true) => format!("{} above and {} below", count(above), count(below)),
175        (true, false) => format!("{} above", count(above)),
176        (false, true) => format!("{} below", count(below)),
177        (false, false) => String::new(),
178    };
179    let mut note = if left.is_empty() {
180        format!("(The whole file: {}.", count(total))
181    } else {
182        format!("(Lines {first}-{last} of {total}.")
183    };
184    if let Some(cut) = cut {
185        note.push(' ');
186        note.push_str(cut);
187    }
188    if !left.is_empty() {
189        note.push_str(&format!(" Not shown: {left}."));
190    }
191    note.push(')');
192    note
193}
194
195/// A number of lines, with the word in the number's own singular or plural.
196fn count(lines: usize) -> String {
197    format!("{lines} line{}", if lines == 1 { "" } else { "s" })
198}
199
200/// The lines and the note under them, a line ending between the two.
201fn join(lines: &str, note: &str) -> String {
202    let mut out = lines.to_owned();
203    if !out.is_empty() && !out.ends_with('\n') {
204        out.push('\n');
205    }
206    out.push_str(note);
207    out
208}