use crate::types::Decision;
use super::config::AiJudgeConfig;
use super::prompt::{build_prompt, build_prompt_lenient};
use super::response::parse_response_with_reason;
pub fn evaluate(
config: &AiJudgeConfig,
language: &str,
code: &str,
cwd: &str,
context: Option<&str>,
project_context: Option<&str>,
) -> (Decision, String) {
let prompt = build_prompt(language, code, cwd, context, project_context);
evaluate_with_prompt(config, prompt)
}
pub fn evaluate_lenient(
config: &AiJudgeConfig,
language: &str,
code: &str,
cwd: &str,
context: Option<&str>,
project_context: Option<&str>,
) -> (Decision, String) {
let prompt = build_prompt_lenient(language, code, cwd, context, project_context);
evaluate_with_prompt(config, prompt)
}
#[cfg(unix)]
fn kill_process_group(pid: u32) {
if pid == 0 {
return;
}
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
fn maybe_strip_ephemeral(argv: Vec<String>, debug_enabled: bool) -> Vec<String> {
if !debug_enabled {
return argv;
}
argv.into_iter().filter(|a| a != "--ephemeral").collect()
}
fn judge_debug_enabled() -> bool {
std::env::var("LONGLINE_AI_JUDGE_DEBUG")
.map(|v| !v.is_empty())
.unwrap_or(false)
}
fn evaluate_with_prompt(config: &AiJudgeConfig, prompt: String) -> (Decision, String) {
let parts: Vec<String> = config
.command
.split_whitespace()
.map(String::from)
.collect();
if parts.is_empty() {
let reason = "AI judge error: command is empty".to_string();
eprintln!("longline: ai-judge command is empty");
return (Decision::Ask, reason);
}
let parts = maybe_strip_ephemeral(parts, judge_debug_enabled());
let timeout = std::time::Duration::from_secs(config.timeout);
let mut cmd = std::process::Command::new(&parts[0]);
cmd.args(&parts[1..])
.arg(&prompt)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let reason = format!("AI judge error: {e}");
eprintln!("longline: ai-judge process error: {e}");
return (Decision::Ask, reason);
}
};
let child_pid = child.id();
let stdout = match child.stdout.take() {
Some(s) => s,
None => {
let reason = "AI judge error: failed to capture stdout".to_string();
eprintln!("longline: ai-judge failed to capture stdout");
return (Decision::Ask, reason);
}
};
let stderr = match child.stderr.take() {
Some(s) => s,
None => {
let reason = "AI judge error: failed to capture stderr".to_string();
eprintln!("longline: ai-judge failed to capture stderr");
return (Decision::Ask, reason);
}
};
let stdout_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = Vec::new();
let mut reader = stdout;
let _ = reader.read_to_end(&mut buf);
buf
});
let stderr_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = Vec::new();
let mut reader = stderr;
let _ = reader.read_to_end(&mut buf);
buf
});
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_status)) => break,
Ok(None) => {}
Err(e) => {
let reason = format!("AI judge error: {e}");
eprintln!("longline: ai-judge process error: {e}");
#[cfg(unix)]
kill_process_group(child_pid);
let _ = child.kill();
let _ = child.wait();
let _ = stdout_handle.join();
let _ = stderr_handle.join();
return (Decision::Ask, reason);
}
}
if start.elapsed() >= timeout {
#[cfg(unix)]
kill_process_group(child_pid);
let _ = child.kill();
let _ = child.wait();
let _ = stdout_handle.join();
let _ = stderr_handle.join();
let reason = format!("AI judge error: timed out after {}s", config.timeout);
eprintln!("longline: ai-judge timed out after {}s", config.timeout);
return (Decision::Ask, reason);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let stdout = stdout_handle.join().unwrap_or_default();
let stderr = stderr_handle.join().unwrap_or_default();
let stdout = String::from_utf8_lossy(&stdout);
let result = parse_response_with_reason(&stdout);
if result.1.contains("unparseable") {
let stderr = String::from_utf8_lossy(&stderr);
eprintln!(
"longline: ai-judge unparseable response\n stdout: {:?}\n stderr: {:?}",
stdout, stderr
);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evaluate_empty_command_returns_ask() {
let config = AiJudgeConfig {
command: String::new(),
timeout: 1,
triggers: super::super::config::TriggersConfig::default(),
};
let (decision, reason) = evaluate(&config, "python3", "print(1)", "/tmp", None, None);
assert_eq!(decision, Decision::Ask);
assert_eq!(reason, "AI judge error: command is empty");
}
#[test]
fn test_evaluate_missing_command_returns_ask_with_error_prefix() {
let config = AiJudgeConfig {
command: "/definitely-not-a-real-ai-judge-command-12345".to_string(),
timeout: 1,
triggers: super::super::config::TriggersConfig::default(),
};
let (decision, reason) = evaluate(&config, "python3", "print(1)", "/tmp", None, None);
assert_eq!(decision, Decision::Ask);
assert!(
reason.starts_with("AI judge error:"),
"Expected error prefix, got: {reason}"
);
}
#[cfg(unix)]
fn make_executable_script(name: &str, contents: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let unique_name = format!(
"{}-{:?}-{}",
name,
std::thread::current().id(),
std::process::id()
);
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("test-tmp")
.join("ai-judge-invoke");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(unique_name);
std::fs::write(&path, contents).unwrap();
std::fs::File::open(&path).unwrap().sync_all().unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
#[cfg(unix)]
#[test]
fn test_evaluate_parses_allow_from_command_output() {
let script = make_executable_script(
"allow.sh",
r#"#!/bin/sh
if [ "$#" -ne 1 ]; then
echo "ASK: missing prompt arg"
exit 0
fi
echo "ALLOW: safe computation"
"#,
);
let config = AiJudgeConfig {
command: script.to_string_lossy().to_string(),
timeout: 10,
triggers: super::super::config::TriggersConfig::default(),
};
let (decision, reason) = evaluate(&config, "python3", "print(1)", "/tmp", None, None);
assert_eq!(decision, Decision::Allow);
assert_eq!(reason, "ALLOW: safe computation");
let _ = std::fs::remove_file(&script);
}
#[test]
#[allow(clippy::type_complexity)]
fn test_evaluate_signature_has_project_context_param() {
let _: fn(
&AiJudgeConfig,
&str,
&str,
&str,
Option<&str>,
Option<&str>,
) -> (Decision, String) = evaluate;
let _: fn(
&AiJudgeConfig,
&str,
&str,
&str,
Option<&str>,
Option<&str>,
) -> (Decision, String) = evaluate_lenient;
}
#[test]
fn test_strip_ephemeral_when_debug_env_set() {
let argv = vec![
"codex".to_string(),
"exec".to_string(),
"--ephemeral".to_string(),
"-m".to_string(),
"gpt-5.4-mini".to_string(),
];
let stripped = maybe_strip_ephemeral(argv.clone(), true);
assert!(
!stripped.iter().any(|a| a == "--ephemeral"),
"--ephemeral should be stripped when debug is enabled: {stripped:?}"
);
assert_eq!(stripped, vec!["codex", "exec", "-m", "gpt-5.4-mini"]);
}
#[test]
fn test_preserve_ephemeral_when_debug_env_unset() {
let argv = vec![
"codex".to_string(),
"exec".to_string(),
"--ephemeral".to_string(),
"-m".to_string(),
"gpt-5.4-mini".to_string(),
];
let stripped = maybe_strip_ephemeral(argv.clone(), false);
assert_eq!(stripped, argv, "argv should be unchanged when debug is off");
}
#[test]
fn test_strip_ephemeral_noop_when_absent() {
let argv = vec![
"codex".to_string(),
"exec".to_string(),
"-m".to_string(),
"gpt-5.4-mini".to_string(),
];
let stripped = maybe_strip_ephemeral(argv.clone(), true);
assert_eq!(stripped, argv, "argv with no --ephemeral is unchanged");
}
#[cfg(unix)]
#[test]
fn test_evaluate_times_out() {
let script = make_executable_script(
"sleep.sh",
r#"#!/bin/sh
sleep 10
echo "ALLOW: safe computation"
"#,
);
let config = AiJudgeConfig {
command: script.to_string_lossy().to_string(),
timeout: 1,
triggers: super::super::config::TriggersConfig::default(),
};
let (decision, reason) = evaluate(&config, "python3", "print(1)", "/tmp", None, None);
assert_eq!(decision, Decision::Ask);
assert_eq!(reason, "AI judge error: timed out after 1s");
let _ = std::fs::remove_file(&script);
}
}