use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::policy::match_tree::CompiledPolicy;
use crate::policy::test_eval;
use crate::policy::Effect;
#[derive(Debug, Clone)]
pub struct TestCase {
pub input: String,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub effect: Effect,
pub summary: String,
pub pinned: bool,
pub changed: bool,
prev_effect: Option<Effect>,
}
#[derive(Debug)]
pub enum Msg {
ScrollUp,
ScrollDown,
JumpTop,
JumpBottom,
TogglePin,
DeleteCase,
ClearHistory,
InputChar(char),
InputBackspace,
InputDelete,
InputLeft,
InputRight,
InputHome,
InputEnd,
InputSubmit,
InputClear,
}
pub struct TestPanel {
cases: Vec<TestCase>,
selected: usize,
scroll_offset: usize,
pub input_active: bool,
input_line: String,
input_cursor: usize,
pub visible: bool,
flash: Option<String>,
}
impl Default for TestPanel {
fn default() -> Self {
Self::new()
}
}
impl TestPanel {
pub fn new() -> Self {
TestPanel {
cases: Vec::new(),
selected: 0,
scroll_offset: 0,
input_active: true,
input_line: String::new(),
input_cursor: 0,
visible: true,
flash: None,
}
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
if self.visible {
self.input_active = true;
}
}
pub fn handle_key(&self, key: KeyEvent) -> Option<Msg> {
if self.input_active {
match key.code {
KeyCode::Enter => Some(Msg::InputSubmit),
KeyCode::Backspace => Some(Msg::InputBackspace),
KeyCode::Delete => Some(Msg::InputDelete),
KeyCode::Left => Some(Msg::InputLeft),
KeyCode::Right => Some(Msg::InputRight),
KeyCode::Home => Some(Msg::InputHome),
KeyCode::End => Some(Msg::InputEnd),
KeyCode::Esc => Some(Msg::InputClear),
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Msg::InputClear)
}
KeyCode::Char(c) => Some(Msg::InputChar(c)),
_ => None,
}
} else {
match key.code {
KeyCode::Char('k') | KeyCode::Up => Some(Msg::ScrollUp),
KeyCode::Char('j') | KeyCode::Down => Some(Msg::ScrollDown),
KeyCode::Char('g') => Some(Msg::JumpTop),
KeyCode::Char('G') => Some(Msg::JumpBottom),
KeyCode::Char('p') => Some(Msg::TogglePin),
KeyCode::Char('d') => Some(Msg::DeleteCase),
KeyCode::Char('x') => Some(Msg::ClearHistory),
KeyCode::Char('i') | KeyCode::Enter => {
None
}
_ => None,
}
}
}
pub fn update(&mut self, msg: Msg, policy: Option<&CompiledPolicy>) -> TestPanelAction {
match msg {
Msg::ScrollUp => {
self.selected = self.selected.saturating_sub(1);
TestPanelAction::None
}
Msg::ScrollDown => {
if !self.cases.is_empty() {
self.selected = (self.selected + 1).min(self.cases.len() - 1);
}
TestPanelAction::None
}
Msg::JumpTop => {
self.selected = 0;
TestPanelAction::None
}
Msg::JumpBottom => {
if !self.cases.is_empty() {
self.selected = self.cases.len() - 1;
}
TestPanelAction::None
}
Msg::TogglePin => {
if let Some(case) = self.cases.get_mut(self.selected) {
case.pinned = !case.pinned;
}
TestPanelAction::None
}
Msg::DeleteCase => {
if self.selected < self.cases.len() {
self.cases.remove(self.selected);
if self.selected >= self.cases.len() && !self.cases.is_empty() {
self.selected = self.cases.len() - 1;
}
}
TestPanelAction::None
}
Msg::ClearHistory => {
self.cases.retain(|c| c.pinned);
self.selected = 0;
TestPanelAction::None
}
Msg::InputChar(c) => {
self.flash = None;
self.input_line.insert(self.input_cursor, c);
self.input_cursor += c.len_utf8();
TestPanelAction::None
}
Msg::InputBackspace => {
if self.input_cursor > 0 {
let prev = self.input_line[..self.input_cursor]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.input_cursor -= prev;
self.input_line.remove(self.input_cursor);
}
TestPanelAction::None
}
Msg::InputDelete => {
if self.input_cursor < self.input_line.len() {
self.input_line.remove(self.input_cursor);
}
TestPanelAction::None
}
Msg::InputLeft => {
if self.input_cursor > 0 {
let prev = self.input_line[..self.input_cursor]
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.input_cursor -= prev;
}
TestPanelAction::None
}
Msg::InputRight => {
if self.input_cursor < self.input_line.len() {
let next = self.input_line[self.input_cursor..]
.chars()
.next()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.input_cursor += next;
}
TestPanelAction::None
}
Msg::InputHome => {
self.input_cursor = 0;
TestPanelAction::None
}
Msg::InputEnd => {
self.input_cursor = self.input_line.len();
TestPanelAction::None
}
Msg::InputSubmit => {
let input = self.input_line.trim().to_string();
if input.is_empty() {
return TestPanelAction::None;
}
let Some(policy) = policy else {
self.flash = Some("No policy loaded".into());
return TestPanelAction::Flash("No policy loaded".into());
};
match test_eval::evaluate_test(&input, policy) {
Ok(result) => {
let effect = result.effect();
let summary = result.summary();
let case = TestCase {
input: input.clone(),
tool_name: result.tool_name,
tool_input: result.tool_input,
effect,
summary,
pinned: false,
changed: false,
prev_effect: None,
};
self.cases.push(case);
self.selected = self.cases.len() - 1;
self.input_line.clear();
self.input_cursor = 0;
self.flash = None;
TestPanelAction::None
}
Err(e) => {
let msg = format!("Parse error: {e:#}");
self.flash = Some(msg.clone());
TestPanelAction::Flash(msg)
}
}
}
Msg::InputClear => {
self.input_line.clear();
self.input_cursor = 0;
self.flash = None;
TestPanelAction::None
}
}
}
pub fn re_evaluate(&mut self, policy: &CompiledPolicy) {
for case in &mut self.cases {
let new_decision = policy.evaluate(&case.tool_name, &case.tool_input);
let new_effect = new_decision.effect;
let new_summary = match &new_decision.reason {
Some(reason) => format!("{} ({reason})", effect_str(new_effect)),
None => effect_str(new_effect).to_string(),
};
let old_effect = case.effect;
case.changed = new_effect != old_effect;
case.prev_effect = Some(old_effect);
case.effect = new_effect;
case.summary = new_summary;
}
}
pub fn toggle_input_focus(&mut self) {
self.input_active = !self.input_active;
}
pub fn view(&self, frame: &mut Frame, area: Rect) {
self.view_with_focus(frame, area, true)
}
pub fn view_with_focus(&self, frame: &mut Frame, area: Rect, focused: bool) {
let border_color = if focused { Color::Blue } else { Color::DarkGray };
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(" Test Console ");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let chunks = Layout::vertical([
Constraint::Min(1), Constraint::Length(2), ])
.split(inner);
self.render_history(frame, chunks[0]);
self.render_input(frame, chunks[1]);
}
fn render_history(&self, frame: &mut Frame, area: Rect) {
if self.cases.is_empty() {
let empty = Paragraph::new(Line::from(Span::styled(
" Type a test below...",
Style::default().fg(Color::DarkGray),
)));
frame.render_widget(empty, area);
return;
}
let visible_height = area.height as usize;
let scroll = if self.selected < self.scroll_offset {
self.selected
} else if self.selected >= self.scroll_offset + visible_height {
self.selected.saturating_sub(visible_height - 1)
} else {
self.scroll_offset
};
let mut lines: Vec<Line> = Vec::new();
let has_pinned = self.cases.iter().any(|c| c.pinned);
for (i, case) in self.cases.iter().enumerate().skip(scroll).take(visible_height) {
if case.pinned && has_pinned {
continue; }
lines.push(self.render_case(i, case));
}
if has_pinned {
let pinned_cases: Vec<(usize, &TestCase)> = self
.cases
.iter()
.enumerate()
.filter(|(_, c)| c.pinned)
.collect();
if !pinned_cases.is_empty() && lines.len() + 1 < visible_height {
lines.push(Line::from(Span::styled(
" ┄┄ pinned ┄┄",
Style::default().fg(Color::DarkGray),
)));
for (i, case) in pinned_cases {
if lines.len() >= visible_height {
break;
}
lines.push(self.render_case(i, case));
}
}
}
let para = Paragraph::new(lines);
frame.render_widget(para, area);
}
fn render_case(&self, index: usize, case: &TestCase) -> Line<'static> {
let is_selected = index == self.selected && !self.input_active;
let pin_marker = if case.pinned { "* " } else { " " };
let changed_badge = if case.changed { " [CHG]" } else { "" };
let effect_icon = match case.effect {
Effect::Allow => "✓",
Effect::Deny => "✗",
Effect::Ask => "?",
};
let effect_color = match case.effect {
Effect::Allow => Color::Green,
Effect::Deny => Color::Red,
Effect::Ask => Color::Yellow,
};
let style = if is_selected {
Style::default()
.bg(Color::DarkGray)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(pin_marker.to_string(), style),
Span::styled(
format!("{effect_icon} "),
if is_selected {
style
} else {
Style::default().fg(effect_color)
},
),
Span::styled(truncate_input(&case.input, 20), style.fg(Color::White)),
Span::styled(
format!(" {}", case.summary),
if is_selected {
style
} else {
Style::default().fg(effect_color)
},
),
];
if !changed_badge.is_empty() {
spans.push(Span::styled(
changed_badge.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
}
Line::from(spans)
}
fn render_input(&self, frame: &mut Frame, area: Rect) {
if area.height < 1 {
return;
}
let prompt_style = if self.input_active {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let input_display = if self.input_line.is_empty() && self.input_active {
"bash \"cmd\", Read { ... }".to_string()
} else {
self.input_line.clone()
};
let input_style = if self.input_line.is_empty() && self.input_active {
Style::default().fg(Color::DarkGray)
} else if self.input_active {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let input_line = Line::from(vec![
Span::styled(" > ", prompt_style),
Span::styled(input_display, input_style),
]);
let mut lines = vec![input_line];
if area.height >= 2 {
if let Some(ref flash) = self.flash {
lines.push(Line::from(Span::styled(
format!(" {flash}"),
Style::default().fg(Color::Red),
)));
} else {
lines.push(Line::from(Span::styled(
" Tab: focus history p: pin",
Style::default().fg(Color::DarkGray),
)));
}
}
let para = Paragraph::new(lines);
frame.render_widget(para, area);
if self.input_active {
let cursor_x = area.x + 3 + self.input_cursor as u16;
let cursor_y = area.y;
if cursor_x < area.x + area.width {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
pub enum TestPanelAction {
None,
Flash(String),
}
fn effect_str(effect: Effect) -> &'static str {
match effect {
Effect::Allow => "allow",
Effect::Deny => "deny",
Effect::Ask => "ask",
}
}
fn truncate_input(input: &str, max: usize) -> String {
if input.len() <= max {
input.to_string()
} else {
format!("{}…", &input[..max - 1])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::match_tree::*;
use crate::policy::manifest_edit;
use std::collections::HashMap;
fn empty_policy() -> CompiledPolicy {
CompiledPolicy {
sandboxes: HashMap::new(),
tree: vec![],
default_effect: Effect::Deny,
default_sandbox: None,
}
}
fn policy_allowing_read() -> CompiledPolicy {
let mut manifest = PolicyManifest {
includes: vec![],
policy: empty_policy(),
};
manifest_edit::upsert_rule(
&mut manifest,
manifest_edit::build_tool_rule("Read", Decision::Allow(None)),
);
manifest.policy
}
#[test]
fn test_submit_and_evaluate() {
let mut panel = TestPanel::new();
let policy = policy_allowing_read();
for c in "Read /tmp/foo".chars() {
panel.update(Msg::InputChar(c), Some(&policy));
}
panel.update(Msg::InputSubmit, Some(&policy));
assert_eq!(panel.cases.len(), 1);
assert_eq!(panel.cases[0].effect, Effect::Allow);
assert!(panel.input_line.is_empty());
}
#[test]
fn test_re_evaluate_detects_changes() {
let mut panel = TestPanel::new();
let policy = policy_allowing_read();
panel.input_line = r#"Read { "file_path": "/tmp/foo" }"#.to_string();
panel.input_cursor = panel.input_line.len();
panel.update(Msg::InputSubmit, Some(&policy));
assert_eq!(panel.cases[0].effect, Effect::Allow);
let deny_policy = empty_policy();
panel.re_evaluate(&deny_policy);
assert_eq!(panel.cases[0].effect, Effect::Deny);
assert!(panel.cases[0].changed);
}
#[test]
fn test_pin_toggle() {
let mut panel = TestPanel::new();
let policy = empty_policy();
panel.input_line = "bash ls".to_string();
panel.input_cursor = panel.input_line.len();
panel.update(Msg::InputSubmit, Some(&policy));
assert!(!panel.cases[0].pinned);
panel.input_active = false;
panel.update(Msg::TogglePin, None);
assert!(panel.cases[0].pinned);
panel.update(Msg::ClearHistory, None);
assert_eq!(panel.cases.len(), 1);
}
#[test]
fn test_delete_case() {
let mut panel = TestPanel::new();
let policy = empty_policy();
panel.input_line = "bash ls".to_string();
panel.input_cursor = panel.input_line.len();
panel.update(Msg::InputSubmit, Some(&policy));
panel.input_line = "bash pwd".to_string();
panel.input_cursor = panel.input_line.len();
panel.update(Msg::InputSubmit, Some(&policy));
assert_eq!(panel.cases.len(), 2);
panel.input_active = false;
panel.selected = 0;
panel.update(Msg::DeleteCase, None);
assert_eq!(panel.cases.len(), 1);
}
#[test]
fn test_no_policy_flash() {
let mut panel = TestPanel::new();
panel.input_line = "bash ls".to_string();
panel.input_cursor = panel.input_line.len();
let action = panel.update(Msg::InputSubmit, None);
assert!(matches!(action, TestPanelAction::Flash(_)));
assert!(panel.cases.is_empty());
}
}