cargo-ai 0.0.11

Build lightweight AI agents with Cargo. Powered by Rust. Declared in JSON.
//! Handles exporting the compiled agent binary to the requested output directory.

use super::build_target::BuildTarget;
use std::fs;
use std::io::{self, ErrorKind};
use std::path::Path;

fn export_binary_to_path(
    source_path: &Path,
    dest_path: &Path,
    force_overwrite: bool,
) -> io::Result<()> {
    if !source_path.exists() {
        return Err(io::Error::new(
            ErrorKind::NotFound,
            format!("Expected binary not found at {:?}", source_path),
        ));
    }

    if dest_path.exists() {
        if !force_overwrite {
            return Err(io::Error::new(
                ErrorKind::AlreadyExists,
                format!(
                    "Output already exists at {:?}. Re-run with --force to overwrite.",
                    dest_path
                ),
            ));
        }
        println!("ℹ️ Existing binary at {:?} will be overwritten.", dest_path);
    }

    fs::copy(source_path, dest_path)?;
    println!("✅ Exported binary to: {:?}", dest_path);
    Ok(())
}

/// Copies the built binary from `.cargo-ai/agents/{agent}/target/...`
/// into either the requested output directory or the current working directory.
pub fn export_binary(
    agent_name: &str,
    force_overwrite: bool,
    build_target: &BuildTarget,
    output_dir: Option<&Path>,
) -> io::Result<()> {
    let project_path = super::agent_workspace_path(agent_name);
    let source_path = build_target.compiled_binary_path(&project_path, agent_name);
    let dest_dir = match output_dir {
        Some(path) => path.to_path_buf(),
        None => std::env::current_dir()?,
    };

    if dest_dir.exists() {
        if !dest_dir.is_dir() {
            return Err(io::Error::new(
                ErrorKind::InvalidInput,
                format!(
                    "Output directory {:?} is not a directory. Re-run with --output-dir <DIR>.",
                    dest_dir
                ),
            ));
        }
    } else {
        fs::create_dir_all(&dest_dir)?;
    }

    let dest_path = build_target.exported_binary_path(&dest_dir, agent_name);

    export_binary_to_path(&source_path, &dest_path, force_overwrite)
}

#[cfg(test)]
mod tests {
    use super::{export_binary, export_binary_to_path};
    use crate::agent_builder::build_target::BuildTarget;
    use std::fs;
    use std::io::ErrorKind;
    use std::path::{Path, PathBuf};
    use std::sync::atomic::{AtomicU64, Ordering};
    use std::time::{SystemTime, UNIX_EPOCH};

    static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);

    fn temp_test_dir() -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after epoch")
            .as_nanos();
        let sequence = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
        let path = std::env::temp_dir().join(format!("cargo-ai-export-test-{nanos}-{sequence}"));
        fs::create_dir_all(&path).expect("test directory should be creatable");
        path
    }

    #[test]
    fn fails_when_destination_exists_without_force() {
        let dir = temp_test_dir();
        let source = dir.join("source-bin");
        let dest = dir.join("dest-bin");
        fs::write(&source, b"source").expect("source should be writable");
        fs::write(&dest, b"existing").expect("dest should be writable");

        let err = export_binary_to_path(&source, &dest, false).expect_err("should fail");
        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
        assert!(err.to_string().contains("--force"));

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn overwrites_when_force_enabled() {
        let dir = temp_test_dir();
        let source = dir.join("source-bin");
        let dest = dir.join("dest-bin");
        fs::write(&source, b"source").expect("source should be writable");
        fs::write(&dest, b"existing").expect("dest should be writable");

        export_binary_to_path(&source, &dest, true).expect("force overwrite should succeed");
        let copied = fs::read(&dest).expect("dest should be readable");
        assert_eq!(copied, b"source");

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn export_creates_missing_output_directory() {
        let workspace = super::super::agent_workspace_path("export_create_dir");
        let build_target = BuildTarget::from_cli(None).expect("default target should resolve");
        let source_path = build_target.compiled_binary_path(&workspace, "export_create_dir");
        let source_parent = source_path
            .parent()
            .expect("compiled binary should have a parent directory");
        fs::create_dir_all(source_parent).expect("source parent should be creatable");
        fs::write(&source_path, b"binary").expect("source binary should be writable");

        let output_dir = temp_test_dir().join("nested").join("bin");
        export_binary(
            "export_create_dir",
            false,
            &build_target,
            Some(Path::new(&output_dir)),
        )
        .expect("export should create missing output directory");

        let exported = build_target.exported_binary_path(&output_dir, "export_create_dir");
        assert!(exported.exists());

        let _ = fs::remove_dir_all(output_dir.parent().unwrap().parent().unwrap());
        let _ = fs::remove_dir_all(workspace);
    }

    #[test]
    fn export_rejects_output_dir_that_is_a_file() {
        let workspace = super::super::agent_workspace_path("export_bad_output_dir");
        let build_target = BuildTarget::from_cli(None).expect("default target should resolve");
        let source_path = build_target.compiled_binary_path(&workspace, "export_bad_output_dir");
        let source_parent = source_path
            .parent()
            .expect("compiled binary should have a parent directory");
        fs::create_dir_all(source_parent).expect("source parent should be creatable");
        fs::write(&source_path, b"binary").expect("source binary should be writable");

        let file_path = temp_test_dir().join("not-a-dir");
        fs::write(&file_path, b"file").expect("file path should be writable");

        let err = export_binary(
            "export_bad_output_dir",
            false,
            &build_target,
            Some(Path::new(&file_path)),
        )
        .expect_err("file output path should fail");
        assert_eq!(err.kind(), ErrorKind::InvalidInput);
        assert!(err.to_string().contains("--output-dir"));

        let _ = fs::remove_file(file_path);
        let _ = fs::remove_dir_all(workspace);
    }
}