looprs 0.5.1

Concise coding assistant REPL — core library
Documentation
use super::ToolArgs;
use super::ToolContext;
use super::error::ToolError;
use serde_json::Value;
use std::fmt::Write as _;
use std::fs;
use std::io::{BufRead, BufReader};

// qual:allow(iosp) reason: "I/O boundary — parses args, reads file, formats output"
pub(super) fn tool_read(args: &Value, ctx: &ToolContext) -> Result<String, ToolError> {
    let args = ToolArgs::new(args);
    let path = args.get_str("path")?;
    let offset = args.get_u64("offset")?.unwrap_or(0) as usize;
    let limit = args.get_u64("limit")?;

    let full_path = ctx.resolve_path(path)?;

    let file = fs::File::open(&full_path).map_err(|_| ToolError::FileNotFound(path.to_string()))?;
    let mut reader = BufReader::new(file);
    let mut line = String::new();

    if limit == Some(0) {
        return Ok(String::new());
    }

    for _ in 0..offset {
        line.clear();
        let bytes_read = reader
            .read_line(&mut line)
            .map_err(|_| ToolError::FileNotFound(path.to_string()))?;
        if bytes_read == 0 {
            return Ok("(EOF)".to_string());
        }
    }

    let mut output = String::new();
    let mut written = 0usize;
    loop {
        if let Some(limit) = limit
            && written >= limit as usize
        {
            break;
        }

        line.clear();
        let bytes_read = reader
            .read_line(&mut line)
            .map_err(|_| ToolError::FileNotFound(path.to_string()))?;
        if bytes_read == 0 {
            if written == 0 {
                return Ok("(EOF)".to_string());
            }
            break;
        }

        let trimmed = line.trim_end_matches(&['\n', '\r'][..]);

        let line_no = offset + written + 1;
        let _ = writeln!(&mut output, "{:4}| {}", line_no, trimmed);
        written += 1;
    }

    while output.ends_with('\n') {
        output.pop();
    }

    Ok(output)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::fs;

    #[test]
    fn read_respects_offset_and_limit() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("a.txt");
        fs::write(&file, "a\nb\nc\n").unwrap();

        let ctx =
            ToolContext::from_working_dir(dir.path().to_path_buf(), crate::fs_mode::FsMode::Write);
        let args = json!({"path": "a.txt", "offset": 1, "limit": 1});

        let out = tool_read(&args, &ctx).unwrap();
        assert!(out.contains("2| b"));
    }

    #[test]
    fn read_blocks_path_traversal() {
        let dir = tempfile::tempdir().unwrap();
        let ctx =
            ToolContext::from_working_dir(dir.path().to_path_buf(), crate::fs_mode::FsMode::Write);
        let args = json!({"path": "../escape.txt"});

        let err = tool_read(&args, &ctx).unwrap_err();
        match err {
            ToolError::PathOutsideWorkingDir(_) => {}
            other => panic!("unexpected error: {other:?}"),
        }
    }
}