zag-cli 0.14.0

A unified CLI for AI coding agents — Claude, Codex, Gemini, Copilot, and Ollama
use anyhow::{Result, bail};
use log::debug;

use crate::config::Config;
use crate::factory::AgentFactory;
use crate::logging;
use zag_agent::review as lib_review;

pub(crate) struct ReviewParams {
    pub provider: String,
    pub uncommitted: bool,
    pub base: Option<String>,
    pub commit: Option<String>,
    pub title: Option<String>,
    pub prompt: Option<String>,
    pub system_prompt: Option<String>,
    pub model: Option<String>,
    pub root: Option<String>,
    pub auto_approve: bool,
    pub add_dirs: Vec<String>,
    pub quiet: bool,
}

pub(crate) async fn run_review(params: ReviewParams) -> Result<()> {
    if !params.uncommitted && params.base.is_none() && params.commit.is_none() {
        bail!("Review requires at least one of: --uncommitted, --base <BRANCH>, --commit <SHA>");
    }

    if params.provider == "codex" {
        return run_codex_review(params).await;
    }

    run_generic_review(params).await
}

async fn run_generic_review(params: ReviewParams) -> Result<()> {
    let ReviewParams {
        provider,
        uncommitted,
        base,
        commit,
        title,
        prompt,
        system_prompt,
        model,
        root,
        auto_approve,
        add_dirs,
        quiet,
    } = params;

    debug!(
        "Starting code review via {provider} (uncommitted={uncommitted}, base={base:?}, commit={commit:?})"
    );

    let diff = lib_review::gather_diff(
        uncommitted,
        base.as_deref(),
        commit.as_deref(),
        root.as_deref(),
    )?;
    let review_prompt = lib_review::build_review_prompt(&diff, title.as_deref(), prompt.as_deref());

    let spinner = logging::spinner(format!("Initializing {provider} for review"));
    let agent = AgentFactory::create(
        &provider,
        system_prompt,
        model,
        root.clone(),
        auto_approve,
        add_dirs,
    )?;
    logging::finish_spinner_quiet(&spinner);

    let model_name = agent.get_model().to_string();
    if !quiet {
        println!("\x1b[32m✓\x1b[0m Review initialized with model {model_name}");
    }

    let (log_coordinator, proc_id) = setup_review_bookkeeping(
        &provider,
        &model_name,
        root.as_deref(),
        format!("review uncommitted={uncommitted} base={base:?} commit={commit:?} title={title:?}"),
    )?;

    let review_result = agent.run(Some(&review_prompt)).await;
    finalize_review_bookkeeping(log_coordinator, proc_id, review_result).await?;
    Ok(())
}

async fn run_codex_review(params: ReviewParams) -> Result<()> {
    let ReviewParams {
        uncommitted,
        base,
        commit,
        title,
        system_prompt,
        model,
        root,
        auto_approve,
        add_dirs,
        quiet,
        ..
    } = params;

    debug!(
        "Starting code review via Codex (uncommitted={uncommitted}, base={base:?}, commit={commit:?})"
    );

    let spinner = logging::spinner("Initializing Codex for review".to_string());
    let mut agent = AgentFactory::create(
        "codex",
        system_prompt,
        model,
        root.clone(),
        auto_approve,
        add_dirs,
    )?;
    logging::finish_spinner_quiet(&spinner);

    let model_name = agent.get_model().to_string();
    if !quiet {
        println!("\x1b[32m✓\x1b[0m Review initialized with model {model_name}");
    }

    let (log_coordinator, proc_id) = setup_review_bookkeeping(
        "codex",
        &model_name,
        root.as_deref(),
        format!("review uncommitted={uncommitted} base={base:?} commit={commit:?} title={title:?}"),
    )?;

    let codex = agent
        .as_any_mut()
        .downcast_mut::<crate::codex::Codex>()
        .expect("Failed to get Codex agent for review");

    let review_result: Result<()> = codex
        .review(
            uncommitted,
            base.as_deref(),
            commit.as_deref(),
            title.as_deref(),
        )
        .await;
    finalize_review_bookkeeping(log_coordinator, proc_id, review_result).await
}

fn setup_review_bookkeeping(
    provider: &str,
    model_name: &str,
    root: Option<&str>,
    prompt_summary: String,
) -> Result<(crate::session_log::SessionLogCoordinator, String)> {
    let review_session_id = uuid::Uuid::new_v4().to_string();
    let workspace_path = root.map(String::from).or_else(|| {
        std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().to_string())
    });
    let log_metadata = crate::session_log::SessionLogMetadata {
        provider: provider.to_string(),
        wrapper_session_id: review_session_id,
        provider_session_id: None,
        workspace_path,
        command: "review".to_string(),
        model: Some(model_name.to_string()),
        resumed: false,
        backfilled: false,
    };
    let live_adapter = crate::session_log::live_adapter_for_provider(
        provider,
        crate::session_log::LiveLogContext {
            root: root.map(String::from),
            provider_session_id: None,
            workspace_path: log_metadata.workspace_path.clone(),
            started_at: chrono::Utc::now(),
            is_worktree: false,
        },
        true,
    );
    let log_coordinator = crate::session_log::SessionLogCoordinator::start(
        &crate::session_log::logs_dir(root),
        log_metadata,
        live_adapter,
    )?;
    let _ = log_coordinator
        .writer()
        .set_global_index_dir(Config::global_base_dir());
    crate::session_log::record_prompt(log_coordinator.writer(), Some(&prompt_summary))?;

    let review_proc_id = uuid::Uuid::new_v4().to_string();
    if let Ok(mut pstore) = zag_agent::process_store::ProcessStore::load() {
        pstore.add(zag_agent::process_store::ProcessEntry {
            id: review_proc_id.clone(),
            pid: std::process::id(),
            session_id: None,
            provider: provider.to_string(),
            model: model_name.to_string(),
            command: "review".to_string(),
            prompt: Some(prompt_summary.chars().take(100).collect()),
            started_at: chrono::Utc::now().to_rfc3339(),
            status: "running".to_string(),
            exit_code: None,
            exited_at: None,
            root: root.map(String::from),
            parent_process_id: std::env::var("ZAG_PROCESS_ID").ok(),
            parent_session_id: std::env::var("ZAG_SESSION_ID").ok(),
        });
        let _ = pstore.save();
    }

    Ok((log_coordinator, review_proc_id))
}

async fn finalize_review_bookkeeping<T>(
    log_coordinator: crate::session_log::SessionLogCoordinator,
    proc_id: String,
    result: Result<T>,
) -> Result<()> {
    match result {
        Ok(_) => {
            log_coordinator.finish(true, None).await?;
            if let Ok(mut pstore) = zag_agent::process_store::ProcessStore::load() {
                pstore.update_status(&proc_id, "exited", Some(0));
                let _ = pstore.save();
            }
            Ok(())
        }
        Err(err) => {
            if let Ok(mut pstore) = zag_agent::process_store::ProcessStore::load() {
                pstore.update_status(&proc_id, "killed", Some(1));
                let _ = pstore.save();
            }
            log_coordinator.finish(false, Some(err.to_string())).await?;
            Err(err)
        }
    }
}