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 std::{
    cell::RefCell,
    path::{Path, PathBuf},
};

use serde::{Deserialize, Serialize};

#[derive(Debug)]
pub enum PathTraversalError {
    OutOfScope {
        requested: String,
        base: String,
    },
    NotFound {
        path: String,
        kind: DirectoryEntryKind,
    },
    Io {
        path: Option<String>,
        source: std::io::Error,
    },
    PermissionDenied {
        path: String,
    },
}

#[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)]
pub struct Location {
    pub path: PathBuf,
    pub line: usize,
    //column: u32,
}

#[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),
        }
    }

    // @todo detect if path already is absolute and warn about it
    pub fn absolute(
        &self,
        path: &Path,
        package_root: Option<&Path>,
    ) -> Result<PathBuf, PathTraversalError> {
        let path = self.cleaned_path(path);
        let matcher = self.get_matcher(package_root);
        let mut start_path = matcher.root().to_path_buf();
        start_path.push(path);
        let relative = matcher
            .normalize(&start_path)
            .ok_or(PathTraversalError::OutOfScope {
                requested: start_path.to_string_lossy().to_string(),
                base: matcher.root().to_string_lossy().to_string(),
            })?;

        let mut path = PathBuf::new();
        path.push(matcher.root());
        path.push(relative);
        Ok(path)
    }

    // @todo detect if path is absolute and warn about it
    // path is relative
    pub fn list_dir(
        &self,
        path: &Path,
        recursive: bool,
        package_root: Option<&Path>,
    ) -> Result<DirectoryListing, PathTraversalError> {
        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 creation.")
            }
        } 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(PathTraversalError::OutOfScope {
                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(PathTraversalError::NotFound {
                path: start_path.to_string_lossy().to_string(),
                kind: DirectoryEntryKind::Directory,
            });
        }

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

            for entry in paths.flatten() {
                let is_dir = entry.path().is_dir();
                let entry =
                    matcher
                        .normalize(entry.path())
                        .ok_or(PathTraversalError::OutOfScope {
                            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)
    }

    pub fn observe_ignore(&self) -> bool {
        self.observe_ignore
    }

    pub fn observe_hidden(&self) -> bool {
        self.observe_hidden
    }

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

pub fn io_error_to_fsa_error(err: std::io::Error, path: &Path) -> PathTraversalError {
    let path_string = path.to_string_lossy().to_string();
    match err.kind() {
        std::io::ErrorKind::NotFound => PathTraversalError::NotFound {
            path: path_string,
            kind: DirectoryEntryKind::File,
        },
        std::io::ErrorKind::PermissionDenied => {
            PathTraversalError::PermissionDenied { path: path_string }
        }
        _ => PathTraversalError::Io {
            path: Some(path_string),
            source: err,
        },
    }
}