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

use anyhow::Result;
use cargo_metadata::MetadataCommand;
use std::{
    cell::RefCell,
    collections::HashMap,
    fs,
    io::ErrorKind,
    path::{Path, PathBuf},
};
use tokio::process::Command;

use crate::llm::tools::{
    DirectoryEntry, DirectoryEntryKind, DirectoryListing, ResourceType, ToolError,
    io_error_to_tool_error,
};

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)]
pub struct PackageResolver {
    pub active_pacmans: Vec<String>,
    pub packages: RefCell<HashMap<PackageId, Package>>,
    pub path_traversal: PathTraversal,
    pub refresh_deps: RefCell<bool>,
    pub workspace_dir: PathBuf,
}

impl PackageResolver {
    pub async fn new(
        workspace_dir: PathBuf,
        observe_hidden: bool,
        observe_ignore: bool,
    ) -> 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()),
                path_traversal: PathTraversal::new(&workspace_dir, observe_hidden, observe_ignore),
                refresh_deps: RefCell::new(true),
                workspace_dir,
            }
        } else {
            PackageResolver {
                active_pacmans: Vec::new(),
                packages: RefCell::new(HashMap::new()),
                path_traversal: PathTraversal::new(&workspace_dir, observe_hidden, observe_ignore),
                refresh_deps: RefCell::new(true),
                workspace_dir,
            }
        }
    }

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

    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(),
                    },
                );
            }
        }
    }
}

#[derive(Clone, Debug)]
pub struct PathTraversal {
    observe_hidden: bool,
    observe_ignore: bool,
    package_matcher: RefCell<Option<ignore::IncrementalIgnore>>,
    workspace_matcher: RefCell<ignore::IncrementalIgnore>,
}

impl PathTraversal {
    pub fn new(root: &Path, observe_hidden: bool, observe_ignore: bool) -> Self {
        let workspace_matcher = Self::make_matcher(root, observe_hidden, observe_ignore);
        Self {
            observe_hidden,
            observe_ignore,
            package_matcher: RefCell::new(None),
            workspace_matcher: RefCell::new(workspace_matcher),
        }
    }

    pub fn absolute(&self, path: &Path, package_root: Option<&Path>) -> Result<PathBuf, ToolError> {
        let matcher = self.get_matcher(package_root);
        let mut start_path = matcher.root().to_path_buf();
        start_path.push(path);
        matcher
            .normalize(&start_path)
            .ok_or(ToolError::PathTraversal {
                requested: start_path.to_string_lossy().to_string(),
                base: matcher.root().to_string_lossy().to_string(),
            })
    }

    // path is relative
    pub fn list_dir(
        &self,
        path: &Path,
        recursive: bool,
        package_root: Option<&Path>,
    ) -> Result<DirectoryListing, ToolError> {
        let mut result = DirectoryListing {
            entries: Vec::new(),
            total_entries: 0,
            truncated: false,
        };
        let path = self.cleaned_path(path);
        let mut maybe_matcher = self.package_matcher.borrow_mut();
        let matcher = if let Some(pckg_root) = package_root {
            if let Some(ref mut pckgm) = *maybe_matcher
                && pckgm.root() == pckg_root
            {
                pckgm
            } else {
                *maybe_matcher = Some(Self::make_matcher(
                    pckg_root,
                    self.observe_hidden,
                    self.observe_ignore,
                ));
                maybe_matcher
                    .as_mut()
                    .expect("Package matcher should exist right after craetion.")
            }
        } else {
            &mut self.workspace_matcher.borrow_mut()
        };

        let mut start_path = matcher.root().to_path_buf();
        start_path.push(&path);
        start_path = matcher
            .normalize(&start_path)
            .ok_or(ToolError::PathTraversal {
                requested: start_path.to_string_lossy().to_string(),
                base: matcher.root().to_string_lossy().to_string(),
            })?;
        if !matcher.root().join(&start_path).is_dir() {
            return Result::Err(ToolError::DirectoryEntryNotFound {
                path: start_path.to_string_lossy().to_string(),
                kind: DirectoryEntryKind::Directory,
                suggestion: "Provide a directory to the list_dir tool.".to_string(),
            });
        }

        let mut backlog = vec![start_path];
        while let Some(dir) = backlog.pop() {
            let absolute_path = matcher.root().join(&dir);
            let paths = match fs::read_dir(absolute_path) {
                Ok(paths) => paths,
                Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                    // @todo Log warning and skip this directory
                    continue;
                }
                Err(err) => return Err(io_error_to_tool_error(err, &path)),
            };

            for entry in paths.flatten() {
                let is_dir = entry.path().is_dir();
                let entry = matcher
                    .normalize(entry.path())
                    .ok_or(ToolError::PathTraversal {
                        requested: entry.path().to_string_lossy().to_string(),
                        base: matcher.root().to_string_lossy().to_string(),
                    })?;

                if matcher.matched(&entry, is_dir).is_ignore() {
                    continue;
                }

                if is_dir {
                    if recursive {
                        backlog.push(entry.clone());
                    }
                    result.entries.push(DirectoryEntry {
                        path: entry.to_string_lossy().to_string(),
                        kind: DirectoryEntryKind::Directory,
                    });
                } else {
                    result.entries.push(DirectoryEntry {
                        path: entry.to_string_lossy().to_string(),
                        kind: DirectoryEntryKind::File,
                    });
                }
            }
        }

        result.total_entries = result.entries.len();

        Ok(result)
    }

    fn cleaned_path(&self, path: &Path) -> PathBuf {
        // clean up attempts of LLM to denote the root directory
        if path == "\"\"" || path == "." {
            PathBuf::new()
        } else {
            PathBuf::from(path)
        }
    }

    fn make_matcher(
        root: &Path,
        observe_hidden: bool,
        observe_ignore: bool,
    ) -> ignore::IncrementalIgnore {
        ignore::WalkBuilder::new(root)
            .require_git(false)
            .parents(true)
            .hidden(observe_hidden)
            .ignore(observe_ignore)
            .git_ignore(observe_ignore)
            .git_exclude(observe_ignore)
            .git_global(observe_ignore)
            .build_matchers()
            .first()
            .expect("We should get exactly one matcher")
            .to_owned()
    }

    fn get_matcher(&self, package_root: Option<&Path>) -> ignore::IncrementalIgnore {
        let mut maybe_matcher = self.package_matcher.borrow_mut();
        if let Some(pckg_root) = package_root {
            if let Some(pckgm) = (*maybe_matcher).as_ref()
                && pckgm.root() == pckg_root
            {
                pckgm.clone()
            } else {
                *maybe_matcher = Some(Self::make_matcher(
                    pckg_root,
                    self.observe_hidden,
                    self.observe_ignore,
                ));
                maybe_matcher
                    .as_ref()
                    .expect("Package matcher should exist right after creation.")
                    .clone()
            }
        } else {
            self.workspace_matcher.borrow().clone()
        }
    }
}