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/>.

*/

pub mod find_package;
pub mod list_dir;
pub mod read_file;
pub mod retrieve_observation;
pub mod search_text;
pub mod web_search;

use crate::{
    common::{
        package_resolver::PackageResolverError,
        path_traversal::{DirectoryEntryKind, PathTraversalError},
    },
    config::{self},
};
use rmcp::{
    RoleClient, ServiceExt,
    model::{ClientCapabilities, ClientInfo, Implementation, Tool as RmcpTool},
    service::{ClientInitializeError, RunningService},
    transport::StreamableHttpClientTransport,
};
use serde::{Deserialize, Serialize};
use std::{
    collections::BTreeMap,
    io::ErrorKind,
    path::{Path, PathBuf},
};

#[derive(Clone, Debug, Deserialize, Serialize)]
struct Range {
    path: PathBuf,
    start: usize,
    end: usize,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FileContent {
    range: Range,
    content: String,
    information: String,
}

pub fn io_error_to_tool_error(err: std::io::Error, path: &Path) -> ToolError {
    let path_string = path.to_string_lossy().to_string();
    match err.kind() {
        ErrorKind::NotFound => ToolError::DirectoryEntryNotFound {
            path: path_string,
            kind: DirectoryEntryKind::File,
            suggestion: "Use list_dir tool to explore available files.".to_string(),
        },
        ErrorKind::PermissionDenied => ToolError::PermissionDenied { path: path_string },
        _ => ToolError::Io {
            path: Some(path_string),
            source: err,
        },
    }
}

#[derive(Debug)]
pub enum ToolError {
    /// File or directory does not exist
    DirectoryEntryNotFound {
        path: String,
        kind: DirectoryEntryKind,
        suggestion: String,
    },
    /// Network error
    NetworkError {
        reason: String,
    },
    /// Entry is not a File
    NotAFile {
        path: String,
        suggestion: String,
    },
    PackageNotFound {
        identifier: String,
    },
    PackageSourceNotAvailable {
        identifier: String,
    },
    /// Path is outside the allowed base directory
    PathTraversal {
        requested: String,
        base: String,
    },
    /// Permission denied accessing path
    PermissionDenied {
        path: String,
    },
    ObservationNotFound {
        identifier: String,
    },
    /// Other IO error
    Io {
        path: Option<String>,
        source: std::io::Error,
    },
    /// error when communicating with a MCP server
    Mcp {
        kind: String,
        info: String,
    }, // @todo better values
}

impl std::fmt::Display for ToolError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ToolError::DirectoryEntryNotFound {
                path,
                kind,
                suggestion,
            } => {
                write!(f, "Error: {} '{}' not found. {}", kind, path, suggestion)
            }
            ToolError::NetworkError { reason } => {
                write!(f, "Error: '{}' when making a network request.", reason)
            }
            ToolError::NotAFile { path, suggestion } => {
                write!(f, "Error: '{}' is not a file. {}", path, suggestion)
            }
            ToolError::PackageNotFound { identifier } => {
                write!(
                    f,
                    "Error: Package with identifier '{}' not found. \
                    Packages follow the pattern '<pacman>:<name>@<version>'. \
                     Use the find_package tool to get available package identifiers.",
                    identifier
                )
            }
            ToolError::PackageSourceNotAvailable { identifier } => {
                write!(
                    f,
                    "Error: The source code for the package with identifier '{}' is not avaialbe locally. \
                    If possible, continue without knowledge from the source code or try to find the required source code online.",
                    identifier
                )
            }
            ToolError::PathTraversal { requested, base } => {
                write!(
                    f,
                    "Error: Path '{}' is outside the allowed directory '{}'. \
                     Only paths within the project root are accessible.",
                    requested, base
                )
            }
            ToolError::PermissionDenied { path } => {
                write!(
                    f,
                    "Error: Permission denied when accessing '{}'. \
                     This file/directory requires elevated permissions. \
                     You cannot access this resource. Continue with available data \
                     or ask the user to check permissions.",
                    path
                )
            }
            ToolError::ObservationNotFound { identifier } => {
                write!(
                    f,
                    "Error: Observation with identifier '{}' not found. \
                     Check the conversation history for valid observation IDs.",
                    identifier
                )
            }
            ToolError::Io { path, source } => {
                if let Some(p) = path {
                    write!(f, "Error reading '{}': {}", p, source)
                } else {
                    write!(f, "Error: {}", source)
                }
            }
            ToolError::Mcp { kind, info } => {
                write!(f, "Error {} accessing MCP with info '{}'.", kind, info)
            }
        }
    }
}
impl std::error::Error for ToolError {}

impl From<PathTraversalError> for ToolError {
    fn from(value: PathTraversalError) -> Self {
        match value {
            PathTraversalError::OutOfScope { requested, base } => {
                ToolError::PathTraversal { requested, base }
            }
            PathTraversalError::NotFound { path, kind } => ToolError::DirectoryEntryNotFound {
                path,
                kind,
                suggestion: "Use list_dir tool to find files and directories.".to_string(),
            },
            PathTraversalError::Io { path, source } => ToolError::Io { path, source },
            PathTraversalError::PermissionDenied { path } => ToolError::PermissionDenied { path },
        }
    }
}

impl From<PackageResolverError> for ToolError {
    fn from(value: PackageResolverError) -> Self {
        match value {
            PackageResolverError::PackageNotFound { identifier } => {
                ToolError::PackageNotFound { identifier }
            }
            PackageResolverError::SourceNotAvailable { identifier } => {
                ToolError::PackageSourceNotAvailable { identifier }
            }
        }
    }
}

// @todo also take sender to send system messages
pub async fn get_mcp_tools(
    tools: &BTreeMap<String, config::McpTool>,
) -> Vec<(RunningService<RoleClient, ClientInfo>, Vec<RmcpTool>)> {
    let mut result = Vec::new();

    for (name, mcp_tool) in tools {
        if let Ok((peer, tools)) = connect_and_fetch_tools(mcp_tool).await {
            result.push((peer, tools));
            // @todo send system message
        } else {
            // @todo send system message about failure
        }
    }

    result
}

async fn connect_and_fetch_tools(
    mcp_tool: &config::McpTool,
) -> Result<(RunningService<RoleClient, ClientInfo>, Vec<RmcpTool>), anyhow::Error> {
    let client = create_client(mcp_tool).await?;
    let tools = client.list_tools(Default::default()).await?.tools;
    Ok((client, tools))
}

async fn create_client(
    mcp_tool: &config::McpTool,
) -> Result<RunningService<RoleClient, ClientInfo>, Box<ClientInitializeError>> {
    let transport = StreamableHttpClientTransport::from_uri(mcp_tool.url.clone());
    let client_info_impl = Implementation::new(
        env!("CARGO_PKG_NAME").to_string(),
        env!("CARGO_PKG_VERSION").to_string(),
    );
    let client_info = ClientInfo::new(ClientCapabilities::default(), client_info_impl);

    client_info.serve(transport).await.map_err(|e| {
        // @todo tracing::error!("client error: {e:?}");
        Box::new(e)
    })
    // @note: This is for rmcp 3.x which rig does not yet support
    // it is left here because I expect rig to migrate soon
    // client_info.serve_with_lifecycle(
    //     transport,
    //     rmcp::ClientLifecycleMode::Auto {
    //         preferred_versions: vec![rmcp::model::ProtocolVersion::V_2026_07_28],
    //         legacy_version: Some(rmcp::model::ProtocolVersion::V_2025_11_25),
    //     },
    // )
    // .await
}

fn ignore_description(hidden: bool, ignore: bool) -> String {
    if hidden && ignore {
        " Hidden files (dotfiles) and files matching .gitignore are excluded."
    } else if hidden {
        " Hidden files (dotfiles) are excluded."
    } else if ignore {
        " Files matching .gitignore are excluded."
    } else {
        ""
    }
    .to_string()
}