monothink 0.5.2

A totally non-agentic coding assistant
/*
* This file is part of monothink.
*
* monothink is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation,
* either version 3 of the License, or (at your option) any later version.
*
* monothink is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with monothink.
* If not, see <https://www.gnu.org/licenses/>.

*/

use crate::common::{package_resolver::PackageResolver, path_traversal::PathTraversal};
use rig::tool::{Tool, ToolContext};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{fs, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;

const MAX_CHARS: usize = 10000;
const MAX_LINES: usize = 1000;

#[derive(Debug, Deserialize, Serialize)]
pub struct ReadFileArgs {
    path: PathBuf,
    #[serde(default)]
    start: Option<usize>,
    #[serde(default)]
    end: Option<usize>,
    #[serde(default)]
    root: Option<String>,
}

pub struct ReadFile {
    package_resolver: Arc<Mutex<PackageResolver>>,
    path_traversal: Arc<Mutex<PathTraversal>>,
    has_pacmans: bool, // stored here both for performance reasions and to avoid a blocking lock
}

impl ReadFile {
    pub async fn new(
        package_resolver: Arc<Mutex<PackageResolver>>,
        path_traversal: Arc<Mutex<PathTraversal>>,
    ) -> Self {
        let has_pacmans = !package_resolver.lock().await.list_pacmans().is_empty();
        Self {
            package_resolver,
            path_traversal,
            has_pacmans,
        }
    }
}

impl Tool for ReadFile {
    const NAME: &'static str = "read_file";

    type Error = super::ToolError;
    type Args = ReadFileArgs;
    type Output = super::FileContent;

    #[tracing::instrument(skip(self, _context))]
    async fn call(
        &self,
        _context: &mut ToolContext,
        args: Self::Args,
    ) -> Result<Self::Output, Self::Error> {
        let package_resolver = self.package_resolver.lock().await;
        let path_traversal = self.path_traversal.lock().await;
        let package_root = package_resolver
            .package_root(args.root.as_deref())?
            .map(PathBuf::from);
        let path = path_traversal.absolute(&args.path, package_root.as_deref())?;

        if path.is_dir() {
            return Err(super::ToolError::NotAFile {
                path: args.path.to_string_lossy().to_string(),
                suggestion: "Use list_dir to explore this directory instead.".to_string(),
            });
        }

        let start = match args.start {
            Some(v) => v.saturating_sub(1),
            None => 0,
        };
        let end = args.end.map(|v| {
            if v > 0 {
                v // we do not substract to convert from one-based to zero-based because the line shall be included which it isn't for slicing
            } else {
                1 // LLMs like to provide 0 or -1 for end of file, we do not support -1 but can support 0
            }
        });

        let mut lines_taken = 0;
        let mut characters_taken = 0;
        let mut information = "The selected range results in no lines.".to_string();
        let content: Vec<String> = fs::read_to_string(path)
            .map_err(|err| super::io_error_to_tool_error(err, &args.path))?
            .lines()
            .skip(start)
            .take_while(|line| {
                let line_width = line.chars().count() + 1; // add the newline that is appended later
                if let Some(end_line) = end
                    && start + lines_taken > end_line
                {
                    information = "".to_string();  // normal exit
                    false
                } else if lines_taken >= MAX_LINES {
                    information = format!("The maximum number of lines ({}) has been reached. Further lines will be skipped.", MAX_LINES);
                    false
                } else if characters_taken + line_width > MAX_CHARS {
                    if lines_taken == 0 {
                        information = format!("The first selected lines exceeded the maximum number of characters ({}). \
                             No lines will be returned.", MAX_CHARS);
                    } else {
                        information = format!("The maximum number of characters ({}) has been reached. Further lines will be skipped.", MAX_CHARS);
                    }
                    false
                } else {
                    lines_taken += 1;
                    characters_taken += line_width;
                    true
                }
            })
            .map(|line| line.to_string())
            //.intersperse("\n".to_string())  // as of 2026-09-17 this is nightly only
            .collect();

        let extract = content.join("\n");

        Ok(super::FileContent {
            range: super::Range {
                path: args.path,
                start: start + 1,
                end: start + lines_taken + 1,
            },
            content: extract.to_owned(),
            information,
        })
    }

    fn description(&self) -> String {
        format!(
            "Read the content of a file, optionally specifying a line range. \
            Lines are 1-indexed and inclusive. \
            If no range is specified, the entire file is returned with the following exceptions: \
            The result is limited to {} lines and {} characters; if the file exceeds these limits, it is clipped.",
            MAX_LINES, MAX_CHARS
        )
    }

    fn parameters(&self) -> serde_json::Value {
        let path_description = format!(
            "Relative path to the file to read *within* the {}. \
                Use an empty string `\"\"` to read a file located at the root of the {}. \
                **Never** include {}absolute paths here.",
            if self.has_pacmans {
                "selected root namespace"
            } else {
                "workspace root"
            },
            if self.has_pacmans {
                "selected scope"
            } else {
                "workspace"
            },
            if self.has_pacmans {
                "package identifiers (like `cargo:...`) or "
            } else {
                ""
            }
        );

        let mut params = json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": path_description
                },
                "start": {
                    "type": "integer",
                    "description": "First line number to read (1-indexed, inclusive). \
                    If not provided, reads from the beginning of the file."
                },
                "end": {
                    "type": "integer",
                    "description": "Last line number to read (1-indexed, inclusive). \
                    If not provided, reads to the end of the file. \
                      If end exceeds the file length, reads until the end."
                },
            },
            "required": ["path"]
        });

        if self.has_pacmans {
            // Add the "root" parameter to properties
            if let Some(props) = params["properties"].as_object_mut() {
                props.insert(
                    "root".to_string(),
                    json!({
                        "type": "string",
                        "description": "Identifier of a package or dependency (e.g., `cargo:crate@version`) to use as the scope for reading. \
                    If omitted, defaults to the workspace root. \
                    This selects *which* codebase, while `path` selects the specific file inside it."
                                        }),
                );
            }
        }

        params
    }

    fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
        rig::tool::ToolExecutionError::from_error(error)
    }
}