monothink 0.4.7

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 Wavething.
* If not, see <https://www.gnu.org/licenses/>.

*/

use rig::tool::{Tool, ToolContext};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
    cmp,
    fs::read_to_string,
    path::{Path, PathBuf},
    sync::Arc,
};
use tokio::sync::Mutex;
use unicode_width::UnicodeWidthStr;

use crate::llm::package_resolver::PackageResolver;

#[derive(Clone, Debug, Deserialize, Serialize)]
struct SearchMatch {
    location: super::Location,
    preview: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SearchResults {
    total_entries: usize,
    returned_entries: usize,
    truncated: bool,
    entries: Vec<SearchMatch>,
    // @todo list entries that could not be accessed
    // failed_entries: Vec<String>
}

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

pub struct SearchText {
    package_resolver: Arc<Mutex<PackageResolver>>,
    has_pacmans: bool, // stored here both for performance reasions and to avoid a blocking lock
    max_results: usize,
    max_line_length: usize,
}

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

impl Tool for SearchText {
    const NAME: &'static str = "search_text";

    type Error = super::ToolError;
    type Args = SearchTextArgs;
    type Output = SearchResults;

    async fn call(
        &self,
        _context: &mut ToolContext,
        args: Self::Args,
    ) -> Result<Self::Output, Self::Error> {
        let package_resolver = self.package_resolver.lock().await;
        let base_dir = package_resolver.base_directory(args.root.as_deref())?;
        let path = super::allowed_canonical_path(&args.path, Path::new(&base_dir))?;
        let entries = super::list_dir(&path, true)?;
        let matches: Vec<SearchMatch> = entries
            .entries
            .iter()
            // filter files
            .filter_map(|e| match e.kind {
                super::DirectoryEntryKind::File => {
                    read_to_string(&e.path).ok().map(|v| (e.path.clone(), v))
                }
                super::DirectoryEntryKind::Directory => None,
            })
            // map to lines that contain the searched text
            .map(|(path, content)| {
                (
                    path,
                    content
                        .lines()
                        .enumerate()
                        .filter(|(_nr, line)| line.contains(&args.text))
                        .map(|(nr, line)| (nr, line.to_owned()))
                        .collect::<Vec<(usize, String)>>(),
                )
            })
            // filter files containing the match
            .filter(|(_path, content)| !content.is_empty())
            // map to search matches
            .flat_map(|(path, content)| {
                content
                    .iter()
                    .map(|(nr, line)| {
                        let line_content = if line.width() <= self.max_line_length {
                            line.clone()
                        } else {
                            let position = line
                                .find(&args.text)
                                .expect("Search pattern should be in preview");
                            let start_position = position.saturating_sub(self.max_line_length / 2);
                            let end_position =
                                cmp::min(position + self.max_line_length / 2, line.width());
                            let prefix = if start_position == 0 {
                                String::new()
                            } else {
                                format!("[clipped {} characters])", start_position)
                            };
                            let postfix = if end_position == line.width() {
                                String::new()
                            } else {
                                format!("[clipped {} characters])", line.width() - end_position)
                            };
                            let clipped_line = line
                                .chars()
                                .skip(start_position)
                                .take(end_position - start_position)
                                .collect::<String>();

                            prefix + &clipped_line + &postfix
                        };
                        SearchMatch {
                            location: super::Location {
                                path: PathBuf::from(path.clone()),
                                line: *nr,
                            },
                            preview: line_content,
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .collect();

        // clip
        // @todo clip during interation to avoid constructing the full vector before clipping
        let start = args.start.unwrap_or_default();
        let requested_matches = &matches[start..];
        let final_matches = if requested_matches.len() > self.max_results {
            &requested_matches[..self.max_results]
        } else {
            requested_matches
        };
        Ok(SearchResults {
            total_entries: matches.len(),
            returned_entries: final_matches.len(),
            truncated: final_matches.len() != matches.len(),
            entries: final_matches.to_vec(),
        })
    }

    fn description(&self) -> String {
        format!(
            "Search for text in all files within the given directory and its subdirectories. \
      Returns matches with file paths, line numbers, and content previews. \
      The search is case-sensitive. Results are clipped at {} entries. \
      Content previews are clipped at {} characters.",
            self.max_results, self.max_line_length
        )
    }

    fn parameters(&self) -> serde_json::Value {
        let path_description = format!(
            "Path to the directory to search in, \
                    relative to {} root directory. \
                    Use an empty string for the workspace root directory. \
                    Search is recursive.",
            if self.has_pacmans {
                "the given"
            } else {
                "workspace"
            }
        );

        let mut params = json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": path_description
                },
                "text": {
                    "type": "string",
                    "description": "The exact text string to search for. \
                    The search is case-sensitive."
                },
                "start": {
                    "type": "integer",
                    "description": "Optional, zero-based index of the first result to return. \
                    Use this to paginate through results if a previous call was truncated."
                },
            },
            "required": ["path", "text"]
        });

        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": "Optional package/dependency identifier to use as the root \
                         directory instead of the workspace root. \
                         If not provided, the workspace root is used."
                    }),
                );
            }
        }

        params
    }

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