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
//! `list_dir`: what is in a folder, as a list of names or as a tree with the sizes.

use std::fs;
use std::path::Path;

use super::workspace::{Workspace, io_problem};

/// At most this many entries are returned.
pub(super) const MAX_ENTRIES: usize = 500;
/// The deepest tree `list_dir` shows; a deeper one is refused with a sentence, not quietly capped.
pub(super) const MAX_DEPTH: i64 = 3;
/// Folders that hold history, build output or downloaded code, not the project's own files; the
/// tree does not go into them, as a search does not.
const SKIPPED: [&str; 3] = [".git", "target", "node_modules"];

/// One entry of the answer, before it is written out.
struct Entry {
    /// How many levels below the listed folder the entry sits; 0 is the folder's own level.
    level: usize,
    /// The name, with a `/` on a folder.
    name: String,
    /// A file's size in bytes; a folder has none.
    size: Option<u64>,
}

impl Entry {
    /// The column its size stands in: where its line ends once it is written out.
    fn column(&self) -> usize {
        self.level * 2 + self.name.chars().count()
    }
}

pub(super) fn list(workspace: &Workspace, path: &str, depth: Option<i64>) -> Result<String, String> {
    let depth = match depth {
        None => 1,
        Some(depth) if (1..=MAX_DEPTH).contains(&depth) => depth as usize,
        Some(depth) => {
            return Err(format!("A depth of {depth} is not one; `list_dir` takes a depth from 1 to {MAX_DEPTH}."));
        }
    };
    let real = workspace.resolve(path)?;
    let shown = workspace.relative(&real);
    if real.is_file() {
        return Err(format!("`{shown}` is a file; use read_file to read it."));
    }
    let mut tree = Vec::new();
    let (mut more, mut seen) = (0, 0);
    walk(workspace.root(), &real, 0, depth, &mut tree, &mut more, &mut seen)
        .map_err(|error| io_problem(&shown, &error))?;
    if seen == 0 {
        return Ok(format!("`{shown}` is empty."));
    }
    // A tree says how big each file is; the plain list of names is what a depth of one has always
    // given, and the model is told in the manual to ask for a depth of two or three for sizes.
    let sized = depth > 1;
    // The sizes line up in one column, so the width is that of the longest name at any level.
    let width =
        if sized { tree.iter().filter(|entry| entry.size.is_some()).map(Entry::column).max().unwrap_or(0) } else { 0 };
    let mut out: Vec<String> = tree
        .iter()
        .map(|entry| {
            let indent = "  ".repeat(entry.level);
            match (sized, entry.size) {
                (true, Some(size)) => {
                    let pad = " ".repeat(width - entry.column());
                    format!("{indent}{}{pad}  {}", entry.name, size_of(size))
                }
                _ => format!("{indent}{}", entry.name),
            }
        })
        .collect();
    if more > 0 {
        out.push(format!("(… and {more} more entries not shown.)"));
    }
    Ok(out.join("\n"))
}

/// The entries of `folder` and, as far as `left` levels go, of the folders below it. Folders come
/// before files, each group in name order. A folder below that cannot be read is left out rather
/// than stopping the walk, and a link is never entered, so the walk cannot leave the workspace.
/// A link whose real location is outside `root`, or nowhere, is named and marked as such, with
/// nothing about what it leads to: no size and no folder's slash, as `read_file` refuses it too.
/// `more` counts the entries the cap left out and `seen` the ones on disk.
///
/// # Errors
///
/// When the folder itself cannot be opened, which the caller names for the model.
fn walk(
    root: &Path,
    folder: &Path,
    level: usize,
    left: usize,
    tree: &mut Vec<Entry>,
    more: &mut usize,
    seen: &mut usize,
) -> std::io::Result<()> {
    let entries = fs::read_dir(folder)?;
    let mut items: Vec<(bool, String, bool, Option<u64>)> = entries
        .filter_map(Result::ok)
        .map(|entry| {
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().into_owned();
            let link = fs::symlink_metadata(&path).is_ok_and(|meta| meta.file_type().is_symlink());
            if link && !fs::canonicalize(&path).is_ok_and(|real| real.starts_with(root)) {
                return (false, format!("{name} (a link out of the working folder)"), false, None);
            }
            let meta = fs::metadata(&path).ok();
            let is_dir = meta.as_ref().is_some_and(std::fs::Metadata::is_dir);
            // Only a real folder is entered. A link to one inside is shown as a folder, as a depth
            // of one has always shown it, but the walk stops there.
            let enterable = !link && is_dir;
            (is_dir, name, enterable, meta.map(|meta| meta.len()))
        })
        .collect();
    items.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
    for (is_dir, name, enterable, size) in items {
        *seen += 1;
        if tree.len() == MAX_ENTRIES {
            *more += 1;
            continue;
        }
        let size = if is_dir { None } else { size };
        // A folder is only entered when the walk may go on inside it, and never into the three
        // folders a search leaves alone, whatever level they are at.
        let below = (enterable && left > 1 && !SKIPPED.contains(&name.as_str())).then(|| folder.join(&name));
        let shown = if is_dir { format!("{name}/") } else { name };
        tree.push(Entry { level, name: shown, size });
        if let Some(child) = below {
            let _ = walk(root, &child, level + 1, left - 1, tree, more, seen);
        }
    }
    Ok(())
}

/// A file's size the way the model reads it: whole bytes, then rounded to one decimal in KB, MB
/// and GB, where 1 KB is 1024 bytes.
fn size_of(bytes: u64) -> String {
    const KB: f64 = 1024.0;
    let size = bytes as f64;
    match size {
        s if s < KB => format!("{bytes} B"),
        s if s < KB * KB => format!("{:.1} KB", s / KB),
        s if s < KB * KB * KB => format!("{:.1} MB", s / KB / KB),
        s => format!("{:.1} GB", s / KB / KB / KB),
    }
}