quvyta-cli 0.1.0-alpha.4

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 working folder turned against the program: gone, replaced, full of links that lead nowhere
//! and paths no file system will take. Every action must end in a sentence the model can act on
//! and nothing may be written outside the folder.

use std::os::unix::fs::{OpenOptionsExt as _, symlink};
use std::path::Path;

use super::super::Outcome;
use super::{Scratch, edit, list, perform, read, search};

/// The sentence an action refused with: it did nothing, and it said why.
fn refused(outcome: &Outcome) -> String {
    assert!(outcome.is_error, "the action should not have been done: {}", outcome.text);
    assert!(!outcome.text.is_empty(), "an error with no sentence in it");
    outcome.text.clone()
}

#[test]
fn a_working_folder_that_is_deleted_while_the_app_is_open_refuses_every_path() {
    let scratch = Scratch::new("hostile-deleted");
    scratch.write("notes.txt", "still here\n");
    let workspace = scratch.workspace();
    let root = workspace.root().to_path_buf();
    std::fs::remove_dir_all(&root).expect("the folder is gone");

    for action in [read("notes.txt"), list(""), search("still", "")] {
        let said = refused(&perform(&workspace, &action));
        // The folder is not there, so every path in it is either outside it or not there: both
        // are sentences the model can act on, and neither writes anything.
        assert!(
            said.contains("cannot be resolved")
                || said.contains("outside the working folder")
                || said.contains("does not exist"),
            "the folder is not there any more, and the sentence says so: {said}"
        );
    }
    assert!(!root.exists(), "nothing was made where the folder was");

    // An edit is asked for like any other and its own folders are made, inside the root's own
    // place, so a folder that was deleted comes back rather than something landing beside it.
    let made = perform(&workspace, &edit("made.txt", "", "x"));
    assert!(!made.is_error && made.text.contains("Created `made.txt`"), "{}", made.text);
    assert_eq!(std::fs::read_to_string(root.join("made.txt")).expect("read"), "x", "and only inside it");
}

#[test]
fn a_working_folder_replaced_by_a_file_is_named_and_not_written_over() {
    let scratch = Scratch::new("hostile-replaced");
    let root = scratch.path().to_path_buf();
    let workspace = scratch.workspace();
    // The folder is taken away and a plain file takes its place while the app is open.
    std::fs::remove_dir_all(&root).expect("the folder is gone");
    std::fs::write(&root, "not a folder\n").expect("a file in its place");

    let listed = perform(&workspace, &list(""));
    assert!(refused(&listed).contains("use read_file"), "{}", listed.text);
    let read = perform(&workspace, &read("notes.txt"));
    assert!(refused(&read).contains("Not a directory"), "the file is in the way: {}", read.text);
    refused(&perform(&workspace, &edit("notes.txt", "not a folder", "changed")));
    assert_eq!(std::fs::read_to_string(&root).expect("read"), "not a folder\n", "the file is as it was");
    // What `Scratch` removes on the way out is a folder, so the file takes its place with it.
    std::fs::remove_file(&root).expect("the file goes too");
}

#[test]
fn a_path_of_three_thousand_characters_is_reported_and_nothing_is_created() {
    let scratch = Scratch::new("hostile-long-path");
    let workspace = scratch.workspace();
    let long = "n".repeat(3000);
    let deep: String = (0..100).map(|number| format!("d{number:02}/")).collect();

    // One name of 3000 characters is longer than a name can be.
    let said = refused(&perform(&workspace, &read(&long)));
    assert!(said.contains("too long"), "a name that long is not one a file has: {said}");
    // Spread over names that can be, the whole path is one a file system can hold, and it holds
    // nothing, so it is named as missing rather than refused.
    let spread = format!("{deep}{long}");
    let said = refused(&perform(&workspace, &read(&spread)));
    assert!(said.contains("does not exist"), "{said}");
    for path in [&long, &spread] {
        refused(&perform(&workspace, &list(path)));
        refused(&perform(&workspace, &search("x", path)));
        refused(&perform(&workspace, &edit(path, "", "x")));
    }
    assert!(!scratch.path().join(&long).exists(), "no file of that name was made");
    // The edit that cannot be written is not left with the hundred folders it needed.
    assert_eq!(std::fs::read_dir(scratch.path()).expect("read").count(), 0, "the folder is as it was");
}

#[test]
fn a_path_with_a_newline_or_a_nul_byte_in_it_is_reported_and_nothing_is_created() {
    let scratch = Scratch::new("hostile-nul-path");
    let workspace = scratch.workspace();

    let newline = perform(&workspace, &read("two\nlines"));
    assert!(refused(&newline).contains("does not exist"), "{}", newline.text);
    let nul = perform(&workspace, &read("two\0lines"));
    assert!(refused(&nul).contains("NUL"), "a NUL byte is not a name: {}", nul.text);

    refused(&perform(&workspace, &edit("two\0lines", "", "x")));
    refused(&perform(&workspace, &search("x", "two\0lines")));
    assert_eq!(std::fs::read_dir(scratch.path()).expect("read").count(), 0, "nothing with such a name was made");
}

#[test]
fn a_link_that_leads_nowhere_or_never_ends_is_refused_with_its_own_words() {
    let scratch = Scratch::new("hostile-links");
    // A link to a file that is not there, and two links that lead to each other.
    symlink(scratch.path().join("not-there.txt"), scratch.path().join("dangling")).unwrap();
    symlink(scratch.path().join("b"), scratch.path().join("a")).unwrap();
    symlink(scratch.path().join("a"), scratch.path().join("b")).unwrap();
    let workspace = scratch.workspace();

    for path in ["dangling", "a", "a/under", "b/deeper/still"] {
        let said = refused(&perform(&workspace, &read(path)));
        assert!(said.contains("leads nowhere"), "{path}: {said}");
    }
    assert!(refused(&perform(&workspace, &list("a"))).contains("leads nowhere"));
    assert!(refused(&perform(&workspace, &search("x", "dangling"))).contains("leads nowhere"));
    refused(&perform(&workspace, &edit("dangling", "", "x")));
    assert!(!scratch.path().join("dangling").exists(), "the broken link is left as it is");
    assert!(!scratch.path().join("a").exists(), "and the loop is not resolved into anything");
}

#[test]
fn a_link_to_the_root_of_the_file_system_is_refused() {
    let scratch = Scratch::new("hostile-root-link");
    symlink("/", scratch.path().join("up")).unwrap();
    let workspace = scratch.workspace();

    for action in [read("up"), read("up/etc/hostname"), list("up"), search("root", "up")] {
        let said = refused(&perform(&workspace, &action));
        assert!(said.contains("outside the working folder"), "{said}");
    }
    assert!(refused(&perform(&workspace, &edit("up/made-here", "", "x"))).contains("outside the working folder"));
    assert!(!Path::new("/made-here").exists(), "nothing was made in the root of the file system");
}

#[test]
fn five_hundred_dots_going_up_are_refused_and_nothing_is_created_above() {
    let scratch = Scratch::new("hostile-many-dots");
    let workspace = scratch.workspace();
    let up = "../".repeat(500);

    for action in [read(&up), list(&up), search("x", &up)] {
        let said = refused(&perform(&workspace, &action));
        assert!(said.contains("outside the working folder"), "{said}");
    }
    let said = refused(&perform(&workspace, &edit(&format!("{up}made.txt"), "", "x")));
    assert!(said.contains("outside the working folder"), "{said}");
    assert!(!scratch.path().parent().expect("a parent").join("made.txt").exists());
    assert_eq!(std::fs::read_dir(scratch.path()).expect("read").count(), 0, "the folder is as it was");
}

#[test]
fn a_file_that_goes_between_the_card_and_the_allow_is_not_written_over() {
    let scratch = Scratch::new("hostile-vanished");
    scratch.write("target.txt", "before\n");
    let workspace = scratch.workspace();

    // What the card showed: the edit works out.
    assert!(workspace.preview(&edit("target.txt", "before", "after")).is_ok());
    // Then the file is gone before the person allows it.
    std::fs::remove_file(scratch.path().join("target.txt")).expect("the file is gone");

    let said = refused(&perform(&workspace, &edit("target.txt", "before", "after")));
    assert!(said.contains("does not exist"), "{said}");
    assert!(!scratch.path().join("target.txt").exists(), "an allowed edit does not make the file again");
    assert_eq!(std::fs::read_dir(scratch.path()).expect("read").count(), 0, "no temporary file was left");
}

/// Runs `action` on its own thread and gives its outcome, or `None` when it has not ended within
/// a generous wait: a read that waits for a writer on a pipe never ends.
fn within_a_while(workspace: &super::Workspace, action: super::super::Action) -> Option<Outcome> {
    let workspace = workspace.clone();
    let (sender, receiver) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = sender.send(perform(&workspace, &action));
    });
    receiver.recv_timeout(std::time::Duration::from_secs(30)).ok()
}

#[test]
fn a_named_pipe_is_refused_at_once_and_nothing_waits_for_a_writer() {
    let scratch = Scratch::new("hostile-pipe");
    rustix::fs::mkfifoat(rustix::fs::CWD, scratch.path().join("pipe"), rustix::fs::Mode::from_raw_mode(0o600))
        .expect("a named pipe");
    let workspace = scratch.workspace();

    let read_it = within_a_while(&workspace, read("pipe")).expect("reading a pipe ends");
    let said = refused(&read_it);
    assert!(said.contains("`pipe` is not a regular file"), "{said}");

    let edit_it = within_a_while(&workspace, edit("pipe", "", "x")).expect("editing a pipe ends");
    assert!(refused(&edit_it).contains("`pipe` is not a regular file"), "{}", edit_it.text);
    // A build that opened the pipe leaves a reader waiting; a writer that comes and goes ends it.
    let _ = std::fs::OpenOptions::new()
        .write(true)
        .custom_flags(rustix::fs::OFlags::NONBLOCK.bits().cast_signed())
        .open(scratch.path().join("pipe"));
}

#[test]
fn a_file_larger_than_what_is_read_is_read_only_that_far_and_says_so() {
    let scratch = Scratch::new("hostile-huge");
    let line = format!("{}\n", "x".repeat(99));
    let size = 20 * 1024 * 1024;
    scratch.write("big.log", line.repeat(size / line.len()));
    let workspace = scratch.workspace();

    let head = perform(&workspace, &read("big.log"));
    assert!(!head.is_error, "{}", head.text);
    assert!(
        head.text.contains("only its first 16 MB are read"),
        "the model is told: {}",
        &head.text[head.text.len() - 300..]
    );

    let past = perform(
        &workspace,
        &super::super::Action::Read {
            path: "big.log".to_owned(),
            lines: super::super::Lines { start: Some(200_000), end: Some(200_010) },
        },
    );
    assert!(refused(&past).contains("only its first 16 MB are read"), "{}", past.text);

    let change = perform(&workspace, &edit("big.log", &line, "y\n"));
    assert!(refused(&change).contains("larger than the 16 MB"), "{}", change.text);
}