oy-cli 0.10.4

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::{Context, Result, anyhow, bail};
use std::path::{Component, Path, PathBuf};

use super::super::ToolContext;
use super::discovery::{build_exclude_set, fff_fuzzy_workspace_paths_with_limit};

pub(super) fn reject_out_of_workspace_path(
    root: &Path,
    path: &str,
    resolved: Option<&Path>,
) -> Result<()> {
    let raw = Path::new(path);
    if raw.is_absolute() {
        bail!("path outside workspace is not allowed: {path} (absolute path)");
    }
    if raw.components().any(|c| matches!(c, Component::ParentDir)) {
        bail!("path outside workspace is not allowed: {path} (parent-directory path)");
    }
    if let Some(resolved) = resolved.filter(|resolved| !within_root(root, resolved)) {
        bail!(
            "path outside workspace is not allowed: {path} -> {}",
            resolved.display()
        );
    }
    Ok(())
}

pub(super) fn resolve_existing_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
    reject_out_of_workspace_path(ctx.root(), path, None)?;
    let joined = ctx.root().join(path);
    let resolved = joined
        .canonicalize()
        .with_context(|| format!("path does not exist: {path}"))?;
    reject_out_of_workspace_path(ctx.root(), path, Some(&resolved))?;
    Ok(resolved)
}

pub(super) fn resolve_read_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
    reject_out_of_workspace_path(ctx.root(), path, None)?;
    match resolve_existing_path(ctx, path) {
        Ok(path) => Ok(path),
        Err(err) => Err(read_path_error_with_suggestions(ctx, path, err)),
    }
}

fn read_path_error_with_suggestions(
    ctx: &ToolContext,
    path: &str,
    err: anyhow::Error,
) -> anyhow::Error {
    let suggestions = read_path_suggestions(ctx, path).unwrap_or_default();
    if suggestions.is_empty() {
        anyhow!(
            "{err}; read requires an exact existing workspace file path; use list for fuzzy discovery"
        )
    } else {
        anyhow!(
            "{err}; did you mean {}? read requires an exact existing workspace file path; use one of the suggested paths in a follow-up read call",
            suggestions.join(", ")
        )
    }
}

fn read_path_suggestions(ctx: &ToolContext, path: &str) -> Result<Vec<String>> {
    let exclude = build_exclude_set(None)?;
    let (items, _) = fff_fuzzy_workspace_paths_with_limit(ctx.root(), path, &exclude, 3)?;
    Ok(items)
}

pub(super) fn resolve_existing_paths(ctx: &ToolContext, path: &str) -> Result<Vec<PathBuf>> {
    match resolve_existing_path(ctx, path) {
        Ok(path) => Ok(vec![path]),
        Err(full_path_error) => {
            let parts = path.split_whitespace().collect::<Vec<_>>();
            if parts.len() <= 1 {
                return Err(full_path_error);
            }
            let mut out = Vec::new();
            for part in parts {
                out.push(resolve_existing_path(ctx, part)?);
            }
            out.sort();
            out.dedup();
            Ok(out)
        }
    }
}

pub(super) fn rel_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .map(|p| p.to_string_lossy().replace('\\', "/"))
        .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
}

pub(super) fn display_path(root: &Path, path: &Path) -> String {
    let mut value = rel_path(root, path);
    if path.is_dir() && !value.ends_with('/') {
        value.push('/');
    }
    value
}

pub(super) fn safe_list_item(root: &Path, path: &Path) -> Option<String> {
    let resolved = path.canonicalize().ok()?;
    if !within_root(root, &resolved) {
        return None;
    }
    Some(display_path(root, path))
}

pub(super) fn within_root(root: &Path, path: &Path) -> bool {
    path == root || path.starts_with(root)
}