typeduck-codex-web 0.4.0

A standalone browser interface and Codex runtime
Documentation
use std::io::Error;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;

use serde::Deserialize;
use serde::Serialize;

const MAX_PATH_BYTES: usize = 32 * 1024;
const PAGE_SIZE: usize = 200;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectoryListRequest {
    pub(crate) path: Option<String>,
    pub(crate) cursor: Option<usize>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectoryListing {
    pub(crate) path: String,
    pub(crate) parent: Option<String>,
    pub(crate) roots: Vec<String>,
    pub(crate) entries: Vec<DirectoryEntry>,
    pub(crate) next_cursor: Option<usize>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectoryEntry {
    pub(crate) name: String,
    pub(crate) path: String,
    pub(crate) symlink: bool,
}

pub(crate) fn list(request: DirectoryListRequest) -> std::io::Result<DirectoryListing> {
    let requested = match request.path {
        Some(path) if !path.is_empty() => PathBuf::from(path),
        Some(_) | None => dirs::home_dir()
            .ok_or_else(|| Error::new(ErrorKind::NotFound, "home directory is unavailable"))?,
    };
    if requested.as_os_str().len() > MAX_PATH_BYTES {
        return Err(Error::new(
            ErrorKind::InvalidInput,
            "directory path is too long",
        ));
    }
    let path = requested.canonicalize().map_err(|error| {
        Error::new(
            error.kind(),
            format!("could not open {}: {error}", requested.display()),
        )
    })?;
    if !path.is_dir() {
        return Err(Error::new(
            ErrorKind::InvalidInput,
            format!("{} is not a directory", path.display()),
        ));
    }
    let mut entries = std::fs::read_dir(&path)?
        .filter_map(std::result::Result::ok)
        .filter_map(directory_entry)
        .collect::<Vec<_>>();
    entries.sort_by(|left, right| {
        left.name
            .to_lowercase()
            .cmp(&right.name.to_lowercase())
            .then_with(|| left.name.cmp(&right.name))
    });
    let cursor = request.cursor.unwrap_or_default().min(entries.len());
    let next_cursor = (cursor + PAGE_SIZE < entries.len()).then_some(cursor + PAGE_SIZE);
    let entries = entries.into_iter().skip(cursor).take(PAGE_SIZE).collect();
    Ok(DirectoryListing {
        path: path_string(&path)?,
        parent: path.parent().map(path_string).transpose()?,
        roots: filesystem_roots(),
        entries,
        next_cursor,
    })
}

pub(crate) fn canonical_directory(path: &str) -> std::io::Result<PathBuf> {
    if path.len() > MAX_PATH_BYTES {
        return Err(Error::new(
            ErrorKind::InvalidInput,
            "directory path is too long",
        ));
    }
    let canonical = Path::new(path).canonicalize()?;
    if !canonical.is_dir() {
        return Err(Error::new(
            ErrorKind::InvalidInput,
            "selected path is not a directory",
        ));
    }
    std::fs::read_dir(&canonical)?;
    path_string(&canonical)?;
    Ok(canonical)
}

pub(crate) fn path_string(path: &Path) -> std::io::Result<String> {
    path.to_str().map(ToOwned::to_owned).ok_or_else(|| {
        Error::new(
            ErrorKind::InvalidData,
            format!("path is not valid Unicode: {}", path.display()),
        )
    })
}

fn directory_entry(entry: std::fs::DirEntry) -> Option<DirectoryEntry> {
    let file_type = entry.file_type().ok()?;
    let symlink = file_type.is_symlink();
    if !(file_type.is_dir() || symlink && entry.path().is_dir()) {
        return None;
    }
    let name = entry.file_name().into_string().ok()?;
    let path = entry.path().into_os_string().into_string().ok()?;
    Some(DirectoryEntry {
        name,
        path,
        symlink,
    })
}

#[cfg(unix)]
fn filesystem_roots() -> Vec<String> {
    vec!["/".to_string()]
}

#[cfg(windows)]
fn filesystem_roots() -> Vec<String> {
    (b'A'..=b'Z')
        .map(|drive| format!("{}:\\", char::from(drive)))
        .filter(|root| Path::new(root).exists())
        .collect()
}

#[cfg(test)]
#[path = "directory_tests.rs"]
mod tests;