arf-console 0.7.0-beta.1

A cross-platform R console written in Rust
use super::protocol;
use std::sync::{
    OnceLock,
    atomic::{AtomicBool, Ordering},
};

/// Session-only switch for interactive `send` approval. It intentionally is
/// not part of Config or startup CLI options.
static SEND_POLICY_ALLOW: OnceLock<AtomicBool> = OnceLock::new();

fn send_policy_allow() -> &'static AtomicBool {
    SEND_POLICY_ALLOW.get_or_init(|| AtomicBool::new(false))
}

pub fn set_send_policy_allow(allow: bool) {
    send_policy_allow().store(allow, Ordering::Release);
}

pub fn send_policy_is_allow() -> bool {
    send_policy_allow().load(Ordering::Acquire)
}

pub struct UserInputApproval {
    pub approved: bool,
    pub wrote_newline: bool,
}

/// Ask for approval immediately before an interactive `user_input` executes.
/// The sender check prevents a timed-out request from being executed after its
/// client has gone away. A half-close cannot be distinguished from the normal
/// request framing, so this is necessarily best effort until the server timeout.
pub fn approve_user_input(
    code: &str,
    reply: &tokio::sync::oneshot::Sender<protocol::IpcResponse>,
) -> UserInputApproval {
    if send_policy_is_allow() {
        return UserInputApproval {
            approved: !reply.is_closed(),
            wrote_newline: false,
        };
    }
    if reply.is_closed() {
        return UserInputApproval {
            approved: false,
            wrote_newline: false,
        };
    }
    use std::io::Write;
    let finish = |approved: bool| {
        // Raw terminals do not translate LF to CRLF; return to column zero
        // explicitly so the agent echo is rendered on the next line.
        if approved {
            print!("\r\n");
        } else {
            print!("\r\n# [arf] IPC send declined.\r\n");
        }
        let _ = std::io::stdout().flush();
        UserInputApproval {
            approved,
            wrote_newline: true,
        }
    };

    // Reedline normally owns raw mode, but the fast path can reach this
    // helper before it starts reading. Preserve whichever state we inherit.
    let was_raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
    if !was_raw && let Err(error) = crossterm::terminal::enable_raw_mode() {
        log::debug!("IPC send approval could not enable raw mode: {error}");
        return finish(false);
    }
    struct RawModeGuard {
        was_raw: bool,
    }
    impl Drop for RawModeGuard {
        fn drop(&mut self) {
            if !self.was_raw {
                let _ = crossterm::terminal::disable_raw_mode();
            }
        }
    }
    let _raw_mode_guard = RawModeGuard { was_raw };

    // On Windows, cooked mode can consume Ctrl+C as a processed signal just
    // after the prompt is displayed; publish the prompt only after the raw
    // mode guard is established so every visible prompt can receive key events.
    let prompt = approval_prompt(code);
    print!("{prompt}");
    let _ = std::io::stdout().flush();

    loop {
        if reply.is_closed() {
            return finish(false);
        }
        if let Err(error) = crossterm::event::poll(std::time::Duration::from_millis(50)) {
            log::debug!("IPC send approval input failed: {error}");
            return finish(false);
        }
        arf_libr::process_r_events();
        // Keep the IPC transport responsive while waiting. R is marked busy
        // by the caller, so later mutating requests receive R_BUSY.
        super::poll_ipc_requests();
        if !crossterm::event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) {
            continue;
        }
        match crossterm::event::read() {
            Ok(event) => {
                let Some(approved) = approval_answer(&event) else {
                    continue;
                };
                let approved = approved && !reply.is_closed();

                // Consume the rest of the answer before raw mode is released;
                // otherwise characters such as Enter can reach reedline.
                while crossterm::event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) {
                    if crossterm::event::read().is_err() {
                        break;
                    }
                }
                return finish(approved);
            }
            Err(error) => {
                log::debug!("IPC send approval input failed: {error}");
                return finish(false);
            }
        }
    }
}

/// Interpret one terminal event as an answer to the approval prompt.
///
/// Only key presses answer. Windows reports key releases, and the release of
/// the Enter that submitted the previous command can still be queued when the
/// prompt appears; treating it as an answer would decline the request before
/// the user sees it.
fn approval_answer(event: &crossterm::event::Event) -> Option<bool> {
    use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
    match event {
        Event::Key(key) if key.kind == KeyEventKind::Press => Some(
            matches!(key.code, KeyCode::Char('y' | 'Y'))
                && matches!(key.modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT),
        ),
        _ => None,
    }
}

fn approval_prompt(code: &str) -> String {
    use crossterm::style::Stylize;

    let escaped = user_input_display(code);
    format!(
        "{}\r\n  {}\r\n{}{}",
        "# [arf] IPC send request:".dark_cyan(),
        escaped.yellow(),
        "# [arf] ".dark_cyan(),
        "Press y to approve, any other key declines: "
            .yellow()
            .bold(),
    )
}

pub(crate) fn user_input_display(code: &str) -> String {
    code.chars()
        .map(|character| {
            if character.is_ascii_graphic() || character == ' ' {
                character.to_string()
            } else {
                character.escape_default().to_string()
            }
        })
        .collect()
}

pub fn reject_user_input_not_approved(reply: tokio::sync::oneshot::Sender<protocol::IpcResponse>) {
    super::pending::reply_error(
        reply,
        protocol::INPUT_NOT_APPROVED,
        "IPC send was not approved",
    );
}

#[cfg(test)]
mod approval_display_tests {
    fn strip_sgr(input: &str) -> String {
        let mut output = String::new();
        let mut chars = input.chars();
        while let Some(character) = chars.next() {
            if character == '\x1b' && chars.next() == Some('[') {
                for character in chars.by_ref() {
                    if character == 'm' {
                        break;
                    }
                }
            } else {
                output.push(character);
            }
        }
        output
    }

    #[test]
    fn approval_prompt_styles_and_separates_heading_code_and_confirmation() {
        let prompt = super::approval_prompt(
            r#"system("SHOULD_BE_VISIBLE")
next"#,
        );

        let plain = format!("{}<END>", strip_sgr(&prompt).replace("\r\n", "\n"));
        insta::assert_snapshot!(
            plain,
            @r###"
# [arf] IPC send request:
  system("SHOULD_BE_VISIBLE")\nnext
# [arf] Press y to approve, any other key declines: <END>
"###
        );
    }

    #[test]
    fn display_includes_content_after_old_preview_limit() {
        let source = format!(
            "{}{}",
            "0123456789".repeat(24),
            r#"system("SHOULD_BE_VISIBLE")"#
        );
        let display = super::user_input_display(&source);

        insta::assert_snapshot!(display, @r###"012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789system("SHOULD_BE_VISIBLE")"###);
    }

    #[test]
    fn display_escapes_control_characters() {
        let source = "line\n\tline\r\0\x1b[31m";
        let display = super::user_input_display(source);

        insta::assert_snapshot!(display, @r###"line\n\tline\r\u{0}\u{1b}[31m"###);
        assert!(!display.contains('\n'));
        assert!(!display.contains('\r'));
        assert!(!display.contains('\0'));
        assert!(!display.contains('\x1b'));
    }
}

#[cfg(test)]
mod approval_answer_tests {
    use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

    fn key(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Event {
        Event::Key(KeyEvent::new_with_kind(code, modifiers, kind))
    }

    #[test]
    fn key_presses_answer_the_prompt() {
        let press = KeyEventKind::Press;
        assert_eq!(
            super::approval_answer(&key(KeyCode::Char('y'), KeyModifiers::NONE, press)),
            Some(true)
        );
        assert_eq!(
            super::approval_answer(&key(KeyCode::Char('Y'), KeyModifiers::SHIFT, press)),
            Some(true)
        );
        assert_eq!(
            super::approval_answer(&key(KeyCode::Enter, KeyModifiers::NONE, press)),
            Some(false)
        );
        assert_eq!(
            super::approval_answer(&key(KeyCode::Char('d'), KeyModifiers::CONTROL, press)),
            Some(false)
        );
    }

    #[test]
    fn key_releases_do_not_answer_the_prompt() {
        // A queued release of the Enter that submitted the previous command
        // must not decline the request.
        let release = KeyEventKind::Release;
        assert_eq!(
            super::approval_answer(&key(KeyCode::Enter, KeyModifiers::NONE, release)),
            None
        );
        assert_eq!(
            super::approval_answer(&key(KeyCode::Char('y'), KeyModifiers::NONE, release)),
            None
        );
    }
}