monothink 0.4.8

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

*/

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::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(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum DirectoryEntryKind {
    File,
    Directory,
}

impl std::fmt::Display for DirectoryEntryKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DirectoryEntryKind::File => {
                write!(f, "File")
            }
            DirectoryEntryKind::Directory => {
                write!(f, "Directory")
            }
        }
    }
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DirectoryEntry {
    pub path: String,
    pub kind: DirectoryEntryKind,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DirectoryListing {
    pub entries: Vec<DirectoryEntry>,
    pub total_entries: usize,
    pub truncated: bool,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
struct Location {
    path: PathBuf,
    line: usize,
    //column: u32,
}

#[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,
    },
    /// Invalid arguments provided to the tool
    InvalidArgs { argument: String, reason: String },
    /// Network error
    NetworkError { reason: String },
    /// A tool or resource is not available
    NotAvailable { name: String, reason: String },
    /// Entry is not a File
    NotAFile { path: String, suggestion: String },
    /// Path is outside the allowed base directory
    PathTraversal { requested: String, base: String },
    /// Permission denied accessing path
    PermissionDenied { path: String },
    /// Resource not found (e.g., observation ID)
    ResourceNotFound {
        resource_type: ResourceType,
        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::InvalidArgs { argument, reason } => {
                write!(
                    f,
                    "Error: Invalid value for argument '{}': {}",
                    argument, reason
                )
            }
            ToolError::NetworkError { reason } => {
                write!(f, "Error: '{}' when making a network request.", reason)
            }
            ToolError::NotAvailable { name, reason } => {
                write!(f, "Error: '{}' is not avaible. {}", name, reason)
            }
            ToolError::NotAFile { path, suggestion } => {
                write!(f, "Error: '{}' is not a file. {}", path, suggestion)
            }
            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::ResourceNotFound {
                resource_type,
                identifier,
            } => {
                write!(
                    f,
                    "Error: {} with identifier '{}' not found. \
                     Check the conversation history for valid {} IDs.",
                    resource_type, identifier, resource_type
                )
            }
            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 {}

#[derive(Debug, Clone)]
pub enum ResourceType {
    Observation,
    Path,
}

impl std::fmt::Display for ResourceType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ResourceType::Observation => {
                write!(f, "Observation")
            }
            ResourceType::Path => {
                write!(f, "Path")
            }
        }
    }
}

// @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
}