quvyta-cli 0.1.0-alpha.5

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
//! The workspace folder: confining paths to it and dispatching actions.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use super::{Action, Outcome, Preview, edit, list, read, run, search};

/// The folder the actions are confined to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
    root: PathBuf,
}

impl Workspace {
    /// The folder the actions are confined to; it is canonicalized, so links are resolved once.
    ///
    /// # Errors
    ///
    /// When the folder does not exist, cannot be resolved or is not a folder.
    pub fn new(root: &Path) -> io::Result<Self> {
        let root = fs::canonicalize(root)?;
        if !root.is_dir() {
            return Err(io::Error::new(io::ErrorKind::NotADirectory, "the workspace must be a folder"));
        }
        Ok(Self { root })
    }

    /// The canonical workspace folder.
    #[must_use]
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// What an action would do, for the approval card. Nothing changes on disk.
    ///
    /// # Errors
    ///
    /// When the action cannot be done as asked (a path outside the workspace, an edit whose text
    /// does not match exactly once); the sentence is the one [`Self::perform`] would give.
    pub fn preview(&self, action: &Action) -> Result<Preview, String> {
        match action {
            Action::Read { path, .. } | Action::List { path, .. } | Action::Search { path, .. } => {
                self.resolve(path).map(|_| Preview::Plain)
            }
            Action::Edit { path, old_text, new_text } => {
                edit::plan(self, path, old_text, new_text).map(|plan| plan.preview())
            }
            Action::Run { command } => Ok(Preview::Command { command: command.clone() }),
        }
    }

    /// Does the action. It never panics: every failure becomes an [`Outcome`] with
    /// `is_error` set and a sentence the model can act on. `cancelled` is asked about every
    /// 50 ms while a command runs, and between the files a search reads; when it answers `true`
    /// the command or the search is stopped.
    pub fn perform(&self, action: &Action, cancelled: &dyn Fn() -> bool) -> Outcome {
        let result = match action {
            Action::Read { path, lines } => read::read(self, path, *lines),
            Action::List { path, depth } => list::list(self, path, *depth),
            Action::Search { pattern, path, glob, ignore_case } => {
                search::search(self, pattern, path, glob.as_deref(), *ignore_case, cancelled, search::LIMIT)
            }
            Action::Edit { path, old_text, new_text } => edit::edit(self, path, old_text, new_text),
            Action::Run { command } => return run::run(self, command, cancelled, run::LIMIT),
        };
        match result {
            Ok(text) => Outcome::done(text),
            Err(text) => Outcome::failed(text),
        }
    }

    /// Does the action the person allowed on a card that showed `shown`. An edit whose file
    /// changed since the card was drawn, so that what would be written is no longer what the
    /// person saw, is not done: the model is told to read the file again.
    pub fn perform_as_shown(&self, action: &Action, shown: Option<&Preview>, cancelled: &dyn Fn() -> bool) -> Outcome {
        self.perform_watching(action, shown, cancelled, &mut |_| {})
    }

    /// [`perform_as_shown`](Self::perform_as_shown), handing the end of a command's output to
    /// `on_tail` while it runs, at most every tenth of a second, so the person sees it go by.
    pub fn perform_watching(
        &self,
        action: &Action,
        shown: Option<&Preview>,
        cancelled: &dyn Fn() -> bool,
        on_tail: &mut dyn FnMut(&str),
    ) -> Outcome {
        if let Action::Run { command } = action {
            return run::run_watching(self, command, cancelled, run::LIMIT, on_tail);
        }
        if let (Action::Edit { path, old_text, new_text }, Some(shown)) = (action, shown) {
            let now = edit::plan(self, path, old_text, new_text).map(|plan| plan.preview());
            if now.as_ref().is_ok_and(|now| now != shown) {
                return Outcome::failed(format!(
                    "`{path}` changed after the person was shown this edit, so nothing was written; read it again and ask anew."
                ));
            }
        }
        self.perform(action, cancelled)
    }

    /// Finds the real location of `path` and makes sure it is inside the workspace.
    ///
    /// The path is relative to the root; an absolute path is accepted only when it lands inside.
    /// Links are followed: an existing path is canonicalized, and for one that does not exist yet
    /// its nearest existing ancestor is, with the missing names added back. Anything whose real
    /// location is outside the root is refused.
    pub(super) fn resolve(&self, path: &str) -> Result<PathBuf, String> {
        let given = Path::new(path);
        let joined = if given.is_absolute() { given.to_path_buf() } else { self.root.join(given) };
        let mut existing = joined.as_path();
        let mut missing = Vec::new();
        while fs::symlink_metadata(existing).is_err() {
            // A missing name followed by `..` cannot be resolved without guessing, so it is
            // refused rather than read lexically.
            match (existing.file_name(), existing.parent()) {
                (Some(name), Some(parent)) => {
                    missing.push(name);
                    existing = parent;
                }
                _ => {
                    return Err(format!("`{path}` cannot be resolved; use a plain path inside the working folder."));
                }
            }
        }
        let real = fs::canonicalize(existing).map_err(|_| {
            format!("`{path}` goes through a link that leads nowhere; use a path inside the working folder.")
        })?;
        let real = missing.iter().rev().fold(real, |acc, name| acc.join(name));
        if real.starts_with(&self.root) {
            Ok(real)
        } else {
            Err(format!("`{path}` is outside the working folder; only paths inside it can be used."))
        }
    }

    /// A resolved path as the model sees it: relative to the root, `.` for the root itself.
    pub(super) fn relative(&self, real: &Path) -> String {
        let relative = real.strip_prefix(&self.root).unwrap_or(real);
        if relative.as_os_str().is_empty() { ".".to_owned() } else { relative.to_string_lossy().into_owned() }
    }
}

/// How much of one file is ever read into memory. The model reads without asking, so a
/// many-gigabyte log in the folder must not be able to take the machine's memory.
pub(super) const READ_CAP: u64 = 16 * 1024 * 1024;

/// What was read of a file: at most [`READ_CAP`] of its bytes, and how big it is.
pub(super) struct Contents {
    /// The bytes read, the whole file when it is not over the cap.
    pub(super) bytes: Vec<u8>,
    /// The size of the whole file.
    pub(super) size: u64,
}

impl Contents {
    /// Whether the bytes are the whole file.
    pub(super) fn whole(&self) -> bool {
        self.size <= READ_CAP
    }
}

/// Reads the file `real`, at most [`READ_CAP`] of it. `Ok(None)` when it is not a regular file:
/// a named pipe would wait for a writer that never comes, and a device can be endless, so
/// neither is opened at all.
pub(super) fn read_capped(real: &Path) -> io::Result<Option<Contents>> {
    use std::io::Read as _;
    let meta = fs::metadata(real)?;
    if !meta.is_file() {
        return Ok(None);
    }
    let mut bytes = Vec::new();
    fs::File::open(real)?.take(READ_CAP).read_to_end(&mut bytes)?;
    // A file that grew since it was looked at is at least as big as what was read of it.
    let size = meta.len().max(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
    Ok(Some(Contents { bytes, size }))
}

/// What the model is told about a path that is neither a file nor a folder.
pub(super) fn not_regular(shown: &str) -> String {
    format!("`{shown}` is not a regular file (a pipe, a device or a socket); it cannot be read or edited.")
}

/// A file-system failure as a sentence for the model.
pub(super) fn io_problem(shown: &str, error: &io::Error) -> String {
    match error.kind() {
        io::ErrorKind::NotFound => format!("`{shown}` does not exist."),
        io::ErrorKind::PermissionDenied => format!("`{shown}` cannot be used: permission denied."),
        _ => format!("`{shown}` cannot be used: {error}."),
    }
}