vv-agent 0.7.1

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::path::{Path, PathBuf};

use super::ToolResultArtifactConfig;

pub(super) fn persist_tool_content(
    content: &str,
    tool_call_id: Option<&str>,
    config: &ToolResultArtifactConfig,
    cycle_index: Option<u32>,
) -> Option<String> {
    let workspace = config.workspace.as_ref()?;
    let artifact_path = build_tool_artifact_path(tool_call_id, &config.artifact_dir, cycle_index);
    if artifact_path.is_absolute() {
        return None;
    }
    std::fs::create_dir_all(workspace).ok()?;
    let workspace_root = workspace.canonicalize().ok()?;
    let target = workspace_root.join(&artifact_path);
    if let Some(parent) = target.parent() {
        if !canonicalize_existing_ancestor(parent)?.starts_with(&workspace_root) {
            return None;
        }
        std::fs::create_dir_all(parent).ok()?;
        if !parent.canonicalize().ok()?.starts_with(&workspace_root) {
            return None;
        }
    }
    if std::fs::symlink_metadata(&target).is_ok()
        && !target.canonicalize().ok()?.starts_with(&workspace_root)
    {
        return None;
    }
    std::fs::write(&target, content).ok()?;
    Some(path_to_string(&artifact_path))
}

fn build_tool_artifact_path(
    tool_call_id: Option<&str>,
    artifact_dir: &Path,
    cycle_index: Option<u32>,
) -> PathBuf {
    let safe_id = sanitize_tool_call_id(tool_call_id.unwrap_or_default())
        .unwrap_or_else(|| format!("tool_result_{}", uuid::Uuid::new_v4().simple()));
    let filename = format!("{safe_id}.txt");
    match cycle_index {
        Some(cycle_index) => artifact_dir
            .join(format!("cycle_{cycle_index}"))
            .join(filename),
        None => artifact_dir.join(filename),
    }
}

fn sanitize_tool_call_id(value: &str) -> Option<String> {
    let safe = value
        .trim()
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    safe.chars()
        .any(|ch| ch.is_ascii_alphanumeric())
        .then_some(safe)
}

fn path_to_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn canonicalize_existing_ancestor(path: &Path) -> Option<PathBuf> {
    let mut current = path;
    while !current.exists() {
        current = current.parent()?;
    }
    current.canonicalize().ok()
}