caretta 0.14.1

caretta agent
use crate::agent::cmd::log;
use crate::agent::types::{AgentEvent, Config, EVENT_SENDER};
use std::path::Path;
use std::process::{Command, Stdio};

const DEFAULT_DEPLOY_COMMAND: &[&str] = &["./scripts/deploy.sh"];

pub fn run_deploy(cfg: &Config) -> Result<(), String> {
    let deploy = &cfg.deploy;
    let Some((program, args)) = deploy.command.split_first() else {
        let guidance = missing_config_guidance();
        log(&guidance);
        notify_done();
        return Err(guidance);
    };

    log("Starting deploy workflow...");
    log(&format!(
        "Deploy environment context: {}",
        context_value(&deploy.environment)
    ));
    log(&format!(
        "Deploy url context: {}",
        context_value(&deploy.url)
    ));
    log(&format!(
        "Deploy command: {}",
        display_command(&deploy.command)
    ));

    if cfg.dry_run {
        log("[dry-run] Would run configured deploy command.");
        notify_done();
        return Ok(());
    }

    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
    let (ok, output) = capture_command_in(program, &arg_refs, Path::new(&cfg.root));
    if !output.trim().is_empty() {
        log(&format!("Deploy output:\n{}", output.trim_end()));
    }

    if ok {
        log("Deploy workflow complete.");
        notify_done();
        Ok(())
    } else {
        log("Deploy command failed.");
        notify_done();
        Err("Deploy command failed.".to_string())
    }
}

fn missing_config_guidance() -> String {
    format!(
        "No `[deploy].command` configured in caretta.toml. Add:\n\n\
         [deploy]\n\
         command = [{}]\n\
         environment = \"staging\"\n\
         url = \"https://staging.example.com\"",
        DEFAULT_DEPLOY_COMMAND
            .iter()
            .map(|part| format!("\"{part}\""))
            .collect::<Vec<_>>()
            .join(", ")
    )
}

fn context_value(value: &str) -> &str {
    if value.trim().is_empty() {
        "(not configured)"
    } else {
        value
    }
}

fn display_command(command: &[String]) -> String {
    command.join(" ")
}

fn capture_command_in(program: &str, args: &[&str], dir: &Path) -> (bool, String) {
    match Command::new(program)
        .args(args)
        .current_dir(dir)
        .stderr(Stdio::piped())
        .stdout(Stdio::piped())
        .output()
    {
        Ok(output) => {
            let combined = format!(
                "{}{}",
                String::from_utf8_lossy(&output.stdout),
                String::from_utf8_lossy(&output.stderr)
            );
            (output.status.success(), combined)
        }
        Err(err) => (false, err.to_string()),
    }
}

fn notify_done() {
    if let Some(tx) = EVENT_SENDER.get() {
        let _ = tx.send(AgentEvent::Done);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::types::{
        Agent, BotSettings, Config, DeployConfig, LocalInferenceConfig, PricingConfig, ScanTargets,
        SkillPaths, TestCommands, VisualRegressionConfig,
    };

    fn test_config(deploy: DeployConfig) -> Config {
        Config {
            agent: Agent::Claude,
            model: String::new(),
            auto_mode: false,
            dry_run: false,
            local_inference: LocalInferenceConfig::default(),
            root: ".".to_string(),
            project_name: "caretta-test".to_string(),
            scan_targets: ScanTargets::default(),
            skill_paths: SkillPaths::default(),
            bootstrap_agent_files: false,
            bootstrap_snapshot: false,
            workflow_preset: "default".to_string(),
            use_subscription: false,
            pricing: PricingConfig::default(),
            bot_settings: BotSettings::default(),
            bot_credentials: None,
            test: TestCommands::default(),
            visual_regression: VisualRegressionConfig::default(),
            deploy,
            workspace: None,
        }
    }

    #[test]
    fn deploy_requires_configured_command() {
        let cfg = test_config(DeployConfig::default());

        let err = run_deploy(&cfg).expect_err("missing command should fail");

        assert!(err.contains("[deploy]"));
        assert!(err.contains("deploy.sh"));
    }

    #[test]
    fn deploy_dry_run_does_not_spawn_command() {
        let mut cfg = test_config(DeployConfig {
            command: vec!["__caretta_missing_deploy_command__".to_string()],
            environment: "staging".to_string(),
            url: "https://staging.example.com".to_string(),
        });
        cfg.dry_run = true;

        run_deploy(&cfg).expect("dry run should not spawn command");
    }

    #[test]
    fn deploy_runs_command_from_repo_root() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(dir.path().join("marker.txt"), "ok").expect("write marker");
        let mut cfg = test_config(DeployConfig {
            command: vec![
                "sh".to_string(),
                "-c".to_string(),
                "test -f marker.txt && printf deploy-ok".to_string(),
            ],
            environment: String::new(),
            url: String::new(),
        });
        cfg.root = dir.path().to_string_lossy().into_owned();

        run_deploy(&cfg).expect("command should run in configured root");
    }
}