vtcode 0.99.1

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use std::sync::Arc;

use anyhow::Result;
use tokio::sync::Notify;
use tokio::task;
use vtcode_tui::app::{InlineHandle, InlineSession, TransientSubmission};

use crate::agent::runloop::unified::overlay_prompt::{OverlayWaitOutcome, show_overlay_and_wait};
use crate::agent::runloop::unified::state::CtrlCState;
use crate::agent::runloop::unified::url_guard::{
    UrlGuardDecision, UrlGuardPrompt, open_external_url, url_guard_decision,
};

pub(crate) struct ExternalUrlGuardContext<'a> {
    handle: &'a InlineHandle,
    session: &'a mut InlineSession,
    ctrl_c_state: &'a Arc<CtrlCState>,
    ctrl_c_notify: &'a Arc<Notify>,
}

impl<'a> ExternalUrlGuardContext<'a> {
    pub(crate) fn new(
        handle: &'a InlineHandle,
        session: &'a mut InlineSession,
        ctrl_c_state: &'a Arc<CtrlCState>,
        ctrl_c_notify: &'a Arc<Notify>,
    ) -> Self {
        Self {
            handle,
            session,
            ctrl_c_state,
            ctrl_c_notify,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExternalUrlGuardOutcome {
    Approved,
    Cancelled,
    Exit,
    Unsupported,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExternalUrlOpenOutcome {
    Opened,
    OpenFailed(String),
    Cancelled,
    Exit,
    Unsupported,
}

pub(crate) async fn request_external_url_guard(
    ctx: ExternalUrlGuardContext<'_>,
    url: &str,
) -> Result<ExternalUrlGuardOutcome> {
    let Some(prompt) = UrlGuardPrompt::parse(url.to_string()) else {
        return Ok(ExternalUrlGuardOutcome::Unsupported);
    };

    let outcome = show_overlay_and_wait(
        ctx.handle,
        ctx.session,
        prompt.request(),
        ctx.ctrl_c_state,
        ctx.ctrl_c_notify,
        |submission| match submission {
            TransientSubmission::Selection(selection) => url_guard_decision(&selection),
            _ => None,
        },
    )
    .await?;

    close_guard_modal(ctx.handle).await;

    Ok(match outcome {
        OverlayWaitOutcome::Submitted(UrlGuardDecision::Approve) => {
            ExternalUrlGuardOutcome::Approved
        }
        OverlayWaitOutcome::Submitted(UrlGuardDecision::Deny)
        | OverlayWaitOutcome::Cancelled
        | OverlayWaitOutcome::Interrupted => ExternalUrlGuardOutcome::Cancelled,
        OverlayWaitOutcome::Exit => ExternalUrlGuardOutcome::Exit,
    })
}

pub(crate) async fn request_external_url_open(
    ctx: ExternalUrlGuardContext<'_>,
    url: &str,
) -> Result<ExternalUrlOpenOutcome> {
    Ok(match request_external_url_guard(ctx, url).await? {
        ExternalUrlGuardOutcome::Approved => {
            if let Err(err) = open_external_url(url) {
                ExternalUrlOpenOutcome::OpenFailed(err.to_string())
            } else {
                ExternalUrlOpenOutcome::Opened
            }
        }
        ExternalUrlGuardOutcome::Cancelled => ExternalUrlOpenOutcome::Cancelled,
        ExternalUrlGuardOutcome::Exit => ExternalUrlOpenOutcome::Exit,
        ExternalUrlGuardOutcome::Unsupported => ExternalUrlOpenOutcome::Unsupported,
    })
}

async fn close_guard_modal(handle: &InlineHandle) {
    handle.close_modal();
    handle.force_redraw();
    task::yield_now().await;
}

#[cfg(test)]
mod tests {
    use super::{
        ExternalUrlGuardContext, ExternalUrlGuardOutcome, ExternalUrlOpenOutcome,
        request_external_url_guard, request_external_url_open,
    };
    use crate::agent::runloop::unified::state::CtrlCState;
    use crate::agent::runloop::unified::url_guard::URL_GUARD_TITLE;
    use std::sync::Arc;
    use tokio::sync::Notify;
    use tokio::sync::mpsc;
    use vtcode_tui::app::{
        InlineCommand, InlineEvent, InlineHandle, InlineListSelection, InlineSession,
        TransientEvent, TransientRequest, TransientSubmission,
    };

    fn session_with_channels() -> (
        InlineHandle,
        mpsc::UnboundedReceiver<InlineCommand>,
        mpsc::UnboundedSender<InlineEvent>,
        InlineSession,
    ) {
        let (command_tx, command_rx) = mpsc::unbounded_channel();
        let (event_tx, event_rx) = mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(command_tx);
        let session = InlineSession {
            handle: handle.clone(),
            events: event_rx,
        };

        (handle, command_rx, event_tx, session)
    }

    #[tokio::test]
    async fn request_external_url_guard_shows_modal_and_accepts_approval() {
        let (handle, mut command_rx, event_tx, mut session) = session_with_channels();
        let ctrl_c_state = Arc::new(CtrlCState::new());
        let ctrl_c_notify = Arc::new(Notify::new());

        let task = tokio::spawn({
            let handle = handle.clone();
            let ctrl_c_state = ctrl_c_state.clone();
            let ctrl_c_notify = ctrl_c_notify.clone();
            async move {
                request_external_url_guard(
                    ExternalUrlGuardContext::new(
                        &handle,
                        &mut session,
                        &ctrl_c_state,
                        &ctrl_c_notify,
                    ),
                    "https://example.com/docs",
                )
                .await
            }
        });

        let command = command_rx.recv().await.expect("show transient command");
        match command {
            InlineCommand::ShowTransient { request } => match *request {
                TransientRequest::List(request) => assert_eq!(request.title, URL_GUARD_TITLE),
                other => panic!("expected list request, got {other:?}"),
            },
            _ => panic!("expected transient command"),
        }

        event_tx
            .send(InlineEvent::Transient(TransientEvent::Submitted(
                TransientSubmission::Selection(InlineListSelection::ConfigAction(
                    "url_guard:approve".to_string(),
                )),
            )))
            .expect("send approval selection");

        let outcome = task.await.expect("join guard task").expect("guard result");
        assert_eq!(outcome, ExternalUrlGuardOutcome::Approved);
    }

    #[tokio::test]
    async fn request_external_url_guard_returns_unsupported_for_non_http_targets() {
        let (handle, mut command_rx, _event_tx, mut session) = session_with_channels();
        let ctrl_c_state = Arc::new(CtrlCState::new());
        let ctrl_c_notify = Arc::new(Notify::new());

        let outcome = request_external_url_guard(
            ExternalUrlGuardContext::new(&handle, &mut session, &ctrl_c_state, &ctrl_c_notify),
            "mailto:test@example.com",
        )
        .await
        .expect("unsupported guard result");

        assert_eq!(outcome, ExternalUrlGuardOutcome::Unsupported);
        assert!(command_rx.try_recv().is_err());
    }

    #[tokio::test]
    async fn request_external_url_open_returns_unsupported_without_opening_browser() {
        let (handle, mut command_rx, _event_tx, mut session) = session_with_channels();
        let ctrl_c_state = Arc::new(CtrlCState::new());
        let ctrl_c_notify = Arc::new(Notify::new());

        let outcome = request_external_url_open(
            ExternalUrlGuardContext::new(&handle, &mut session, &ctrl_c_state, &ctrl_c_notify),
            "mailto:test@example.com",
        )
        .await
        .expect("unsupported open result");

        assert_eq!(outcome, ExternalUrlOpenOutcome::Unsupported);
        assert!(command_rx.try_recv().is_err());
    }
}