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 rig::tool::{Tool, ToolContext};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{path::PathBuf, sync::Arc};
use tokio::sync::Mutex;

use crate::common::{
    package_resolver::PackageResolver,
    path_traversal::{DirectoryListing, PathTraversal},
};

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

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

// @todo check access rights (i.e. calculated path must be below base_dir)
impl ListDir {
    pub async fn new(
        package_resolver: Arc<Mutex<PackageResolver>>,
        path_traversal: Arc<Mutex<PathTraversal>>,
        max_results: Option<usize>,
    ) -> Self {
        let has_pacmans = !package_resolver.lock().await.list_pacmans().is_empty();
        let ignore_description = {
            let pt = path_traversal.lock().await;
            super::ignore_description(pt.observe_hidden(), pt.observe_ignore())
        };

        Self {
            package_resolver,
            path_traversal,
            max_results,
            has_pacmans,
            ignore_description,
        }
    }
}
impl Tool for ListDir {
    const NAME: &'static str = "list_dir";

    type Error = super::ToolError;
    type Args = ListDirArgs;
    type Output = DirectoryListing;

    #[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 mut result = path_traversal.list_dir(&args.path, false, package_root.as_deref())?;
        // @todo clip long lists but preserve total entries (add actual entries as info)
        // @todo this is brickle, because it also includes permission denied errors, we should log the error and go on
        result.total_entries = result.entries.len();
        if let Some(max_results) = self.max_results
            && result.entries.len() > max_results
        {
            result.entries.truncate(max_results);
            result.truncated = true;
        }

        Ok(result)
    }

    fn description(&self) -> String {
        if self.has_pacmans {
            format!(
                "List the files and directories in the given directory (relative to the selected root namespace), \
        returning {} entries. Use an empty string \"\" for the selected root namespace. \
        Results include both files and subdirectories with their types.{}",
                match self.max_results {
                    Some(val) => format!("at most {}", val),
                    None => "all".to_string(),
                },
                self.ignore_description
            )
        } else {
            format!(
                "List the files and directories in the given directory (relative to workspace root), \
        returning {} entries. Use an empty string \"\" for the workspace root. \
        Results include both files and subdirectories with their types.{}",
                match self.max_results {
                    Some(val) => format!("at most {}", val),
                    None => "all".to_string(),
                },
                self.ignore_description
            )
        }
    }

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

        let mut params = json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": path_description
                },
                // "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"]
        });

        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 starting namespace. \
                        If omitted, defaults to the workspace root. \
                        This acts as a scope selector, completely independent of the relative `path` parameter.",
                    }),
                );
            }
        }

        params
    }

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