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 anyhow::Result;
use cargo_metadata::MetadataCommand;
use std::{cell::RefCell, collections::HashMap, path::PathBuf};
use tokio::process::Command;

use crate::llm::tools::{ResourceType, ToolError};

type PackageId = String;

#[derive(Debug, Clone, PartialEq)]
pub struct Package {
    pub id: PackageId,
    pub name: String,
    pub source: Option<PathBuf>,
    pub version: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct PackageResolver {
    pub active_pacmans: Vec<String>,
    pub packages: RefCell<HashMap<PackageId, Package>>,
    pub refresh_deps: RefCell<bool>,
    pub workspace_dir: PathBuf,
}

impl PackageResolver {
    pub async fn new(workspace_dir: PathBuf) -> PackageResolver {
        let cargo_success = Command::new("cargo")
            .args(["metadata", "--no-deps", "--format-version", "1"])
            .current_dir(&workspace_dir)
            .output()
            .await
            .map_or_else(
                |err| {
                    // @Todo log error
                    false
                },
                |val| val.status.success(),
            );

        if cargo_success {
            PackageResolver {
                active_pacmans: vec!["cargo".to_string()],
                packages: RefCell::new(HashMap::new()),
                refresh_deps: RefCell::new(true),
                workspace_dir,
            }
        } else {
            PackageResolver {
                active_pacmans: Vec::new(),
                packages: RefCell::new(HashMap::new()),
                refresh_deps: RefCell::new(true),
                workspace_dir,
            }
        }
    }

    // @todo filter by language or pacman
    pub fn base_directory(&self, id: Option<&str>) -> Result<String, ToolError> {
        match id {
            Some(pckg_id) => {
                // allow the llm to explicitly specifiy a workspace root
                if pckg_id == "" || pckg_id == "\"\"" || pckg_id == "workspace" {
                    Ok(self.workspace_dir.to_string_lossy().to_string())
                } else {
                    self.package_source(&pckg_id)
                }
            }
            None => Ok(self.workspace_dir.to_string_lossy().to_string()),
        }
    }

    pub fn package_source(&self, id: &str) -> Result<String, ToolError> {
        self.refresh_dependencies();
        let packages = self.packages.borrow();
        packages
            .get(id)
            .ok_or(ToolError::InvalidArgs {
                argument: id.to_string(),
                reason: format!(
                    "id '{}' does not denote a package. Search packages with find_package().", // @todo don't hard-code the tool here
                    id,
                ),
            })
            .and_then(|pckg| match &pckg.source {
                Some(path) => Ok(path.to_string_lossy().to_string()),
                None => Err(ToolError::ResourceNotFound {
                    resource_type: ResourceType::Path,
                    identifier: id.to_string(),
                }),
            })
    }

    pub fn list_pacmans(&self) -> Vec<String> {
        self.active_pacmans.clone()
    }
    // @todo dependencies(pacman: String, package: Option<PackageId>) -> Result<Vec<Dependency>>

    // @todo filter by language or pacman
    pub fn search_dependency(&self, pattern: &str) -> Vec<String> {
        self.refresh_dependencies();
        let packages = self.packages.borrow();
        packages
            .iter()
            .filter(|pckg| pckg.1.name.contains(pattern))
            .map(|pckg| pckg.0.to_owned())
            .collect()
    }

    // populate dependencies on first run, update periodically
    fn refresh_dependencies(&self) {
        let mut refresh_deps = self.refresh_deps.borrow_mut();
        let mut packages = self.packages.borrow_mut();
        if *refresh_deps {
            *refresh_deps = false;
            let metadata = MetadataCommand::new()
                .current_dir(&self.workspace_dir)
                .exec()
                .unwrap();
            for package in metadata.packages.iter() {
                packages.insert(
                    format!("{}:{}@{}", "cargo", package.name, package.version),
                    Package {
                        id: package.id.repr.clone(),
                        name: package.name.to_string(),
                        source: package
                            .manifest_path
                            .parent()
                            .map(|p| p.to_path_buf().into_std_path_buf()),
                        version: package.version.to_string(),
                    },
                );
            }
        }
    }
}

// if projects are present, tools get a paramter scope that could either be "workspace" or one of the dependency managers (e.g. cargo, npm).
// Tools list the options to the LLM.
// For each project, get the direct (declared) dependencies as well as all dependencies
// list_deps tool just grabs the direct dependencies (i.e. workspace dependencies). could also list all dependencies of a given package
// parameters: package = workspace | name, project: e.g. cargo
// find_deps tool returns all dependencies that match/contain a pattern/name
// for a start, refresh the list on each tool call.
// later watch relevant files and refresh after n (e.g. n=5) calls to outrule missing updates
// cargo: just run `cargo metadata --format-version 1 --no-deps` and see if it works
// if yes, instantiate projects for rust, otherwise disable rust