agentcarousel 0.2.1

Check YAML/TOML fixtures for agents and skills, run cases (mock or live), and keep run rows in SQLite for reports and evidence export.
Documentation
use clap::{Parser, Subcommand};
use flate2::write::GzEncoder;
use flate2::Compression;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tar::Builder;

use super::exit_codes::ExitCode;

/// Pack or verify fixture bundles (manifest + tarball).
#[derive(Debug, Parser)]
pub struct BundleArgs {
    #[command(subcommand)]
    command: BundleCommand,
}

#[derive(Debug, Subcommand)]
enum BundleCommand {
    /// Update manifest sha256s and write a .tar.gz (default name from dir).
    Pack {
        /// Directory containing bundle.manifest.json (default: current directory).
        #[arg(value_name = "DIR", default_value = ".")]
        dir: PathBuf,
        #[arg(short = 'o', long)]
        out: Option<PathBuf>,
    },
    /// Verify hashes vs files (bundle dir, path to bundle.manifest.json, or .tar.gz).
    Verify { path: Option<PathBuf> },
}

pub fn run_bundle(args: BundleArgs) -> i32 {
    match args.command {
        BundleCommand::Pack { dir, out } => match pack_bundle(&dir, out.as_deref()) {
            Ok(path) => {
                println!("created {}", path.display());
                ExitCode::Ok.as_i32()
            }
            Err(err) => {
                eprintln!("error: {err}");
                ExitCode::RuntimeError.as_i32()
            }
        },
        BundleCommand::Verify { path } => match verify_bundle(path.as_deref()) {
            Ok(resolved) => {
                println!("bundle verify: OK ({})", resolved.display());
                ExitCode::Ok.as_i32()
            }
            Err(err) => {
                eprintln!("error: {err}");
                ExitCode::RuntimeError.as_i32()
            }
        },
    }
}

fn pack_bundle(dir: &Path, out: Option<&Path>) -> Result<PathBuf, String> {
    let dir = dir.canonicalize().map_err(|err| err.to_string())?;
    let manifest_path = dir.join("bundle.manifest.json");
    if manifest_path.exists() {
        // Update sha256 entries so bundle contents are self-consistent.
        update_manifest_hashes(&manifest_path, &dir)?;
    } else {
        return Err("bundle.manifest.json not found".to_string());
    }

    let out_path = out.map(|path| path.to_path_buf()).unwrap_or_else(|| {
        let dir_name = dir
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("bundle");
        PathBuf::from(format!("{dir_name}.tar.gz"))
    });

    let archive = fs::File::create(&out_path).map_err(|err| err.to_string())?;
    let encoder = GzEncoder::new(archive, Compression::default());
    let mut tar = Builder::new(encoder);
    tar.append_dir_all(".", &dir)
        .map_err(|err| err.to_string())?;
    tar.finish().map_err(|err| err.to_string())?;
    Ok(out_path)
}

fn is_gzip_tarball(path: &Path) -> bool {
    let s = path.to_string_lossy();
    s.ends_with(".tar.gz") || s.ends_with(".tgz")
}

/// Returns the bundle root or archive path that was verified (for user feedback).
fn verify_bundle(path: Option<&Path>) -> Result<PathBuf, String> {
    let path = path.unwrap_or_else(|| Path::new("."));
    if path.is_file() {
        let file_name = path.file_name().and_then(|name| name.to_str());
        if file_name == Some("bundle.manifest.json") {
            let root = path
                .parent()
                .filter(|parent| !parent.as_os_str().is_empty())
                .unwrap_or(Path::new("."));
            verify_manifest(path, root)?;
            return Ok(root.to_path_buf());
        }
        if is_gzip_tarball(path) {
            let tmp_dir =
                std::env::temp_dir().join(format!("agentcarousel-bundle-{}", std::process::id()));
            if tmp_dir.exists() {
                fs::remove_dir_all(&tmp_dir).map_err(|err| err.to_string())?;
            }
            fs::create_dir_all(&tmp_dir).map_err(|err| err.to_string())?;
            let archive = fs::File::open(path).map_err(|err| err.to_string())?;
            let decoder = flate2::read::GzDecoder::new(archive);
            let mut tar = tar::Archive::new(decoder);
            tar.unpack(&tmp_dir).map_err(|err| err.to_string())?;
            let manifest_path = tmp_dir.join("bundle.manifest.json");
            verify_manifest(&manifest_path, &tmp_dir)?;
            fs::remove_dir_all(&tmp_dir).ok();
            return Ok(path.to_path_buf());
        }
        return Err(format!(
            "expected a bundle directory, bundle.manifest.json, or a .tar.gz archive; got {}",
            path.display()
        ));
    }
    let manifest_path = path.join("bundle.manifest.json");
    verify_manifest(&manifest_path, path)?;
    Ok(path.to_path_buf())
}

fn update_manifest_hashes(manifest_path: &Path, root: &Path) -> Result<(), String> {
    let contents = fs::read_to_string(manifest_path).map_err(|err| err.to_string())?;
    let mut manifest: Value = serde_json::from_str(&contents).map_err(|err| err.to_string())?;
    update_entries(&mut manifest, "fixtures", root)?;
    update_entries(&mut manifest, "mocks", root)?;
    let rendered = serde_json::to_string_pretty(&manifest).map_err(|err| err.to_string())?;
    let mut file = fs::File::create(manifest_path).map_err(|err| err.to_string())?;
    file.write_all(rendered.as_bytes())
        .map_err(|err| err.to_string())?;
    Ok(())
}

fn update_entries(manifest: &mut Value, field: &str, root: &Path) -> Result<(), String> {
    let Some(entries) = manifest
        .get_mut(field)
        .and_then(|value| value.as_array_mut())
    else {
        return Ok(());
    };
    for entry in entries {
        let Some(path_value) = entry.get("path").and_then(|value| value.as_str()) else {
            return Err(format!("{field} entry missing path"));
        };
        let file_path = root.join(path_value);
        let hash = sha256_file(&file_path)?;
        entry["sha256"] = Value::String(hash);
    }
    Ok(())
}

fn verify_manifest(manifest_path: &Path, root: &Path) -> Result<(), String> {
    if !manifest_path.exists() {
        return Err("bundle.manifest.json not found".to_string());
    }
    let contents = fs::read_to_string(manifest_path).map_err(|err| err.to_string())?;
    let manifest: Value = serde_json::from_str(&contents).map_err(|err| err.to_string())?;
    verify_entries(&manifest, "fixtures", root)?;
    verify_entries(&manifest, "mocks", root)?;
    Ok(())
}

fn verify_entries(manifest: &Value, field: &str, root: &Path) -> Result<(), String> {
    let Some(entries) = manifest.get(field).and_then(|value| value.as_array()) else {
        return Ok(());
    };
    for entry in entries {
        let Some(path_value) = entry.get("path").and_then(|value| value.as_str()) else {
            return Err(format!("{field} entry missing path"));
        };
        let Some(expected) = entry.get("sha256").and_then(|value| value.as_str()) else {
            return Err(format!("{field} entry missing sha256 for {path_value}"));
        };
        let file_path = root.join(path_value);
        let actual = sha256_file(&file_path)?;
        if actual != expected {
            return Err(format!(
                "{field} hash mismatch for {path_value}: expected {expected}, got {actual}"
            ));
        }
    }
    Ok(())
}

fn sha256_file(path: &Path) -> Result<String, String> {
    let contents = fs::read(path).map_err(|err| err.to_string())?;
    let mut hasher = Sha256::new();
    hasher.update(contents);
    Ok(format!("{:x}", hasher.finalize()))
}