#![warn(missing_docs)]
mod acp;
mod batch;
pub mod cli;
mod errors;
mod pool;
pub mod presets;
mod typed_strings;
pub mod vision;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use acp::AcpClient;
use vision::VisionApiConfig;
pub use acp::build_codex_acp_args;
pub use batch::{
AggregateStatus, AssetChange, AssetRubricReport, AssetRubricResult, AssetSnapshot,
BatchRubricConfig, BatchRubricReport, BatchRubricRun, IssueClassificationInput,
IssueClassifier, IssueRecommendation, RecommendationSeverity, SelectionMode, diff_snapshots,
select_changed,
};
pub use cli::Cli;
pub use errors::{PoolError, RateLimitEvent, RubricError};
pub use pool::{LogCaptureConfig, LogPathMode, PoolConfig, PoolStats, RubricPool};
pub use typed_strings::{RubricEffort, RubricVerdictStatus};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RubricVerdict {
pub verdict: RubricVerdictStatus,
pub reason: String,
#[serde(default, deserialize_with = "deserialize_anomalies")]
pub anomalies: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct RubricOptions {
pub model: Option<String>,
pub effort: Option<RubricEffort>,
pub system_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RubricRunConfig {
pub codex_acp_binary: PathBuf,
pub acp_args: Vec<String>,
pub extra_env: Vec<(OsString, OsString)>,
pub cwd: Option<PathBuf>,
}
impl Default for RubricRunConfig {
fn default() -> Self {
Self {
codex_acp_binary: default_codex_acp_binary(),
acp_args: build_codex_acp_args(
DEFAULT_CODEX_ACP_MODEL,
DEFAULT_CODEX_ACP_REASONING_EFFORT,
),
extra_env: Vec::new(),
cwd: None,
}
}
}
pub const DEFAULT_SYSTEM_PROMPT: &str = presets::UI_REGRESSION_SYSTEM_PROMPT;
pub const DEFAULT_CODEX_ACP_MODEL: &str = "gpt-5.4-mini";
pub const DEFAULT_CODEX_ACP_REASONING_EFFORT: &str = "medium";
pub const DEFAULT_VISION_PROMPT: &str = "\
You are a UI description engine. Given a screenshot, produce a structured JSON \
description of all visible user interface elements, their text content, layout, \
and any visual issues (clipping, overlap, blank regions, contrast problems). \
Output ONLY valid JSON with no additional text.";
#[must_use]
pub fn default_options() -> RubricOptions {
RubricOptions {
model: Some(DEFAULT_CODEX_ACP_MODEL.to_string()),
effort: Some(DEFAULT_CODEX_ACP_REASONING_EFFORT.into()),
system_prompt: Some(DEFAULT_SYSTEM_PROMPT.to_string()),
}
}
#[must_use]
pub fn default_codex_acp_binary() -> PathBuf {
PathBuf::from("codex-acp")
}
pub fn encode_png(png_path: &Path) -> Result<String, PoolError> {
let bytes = std::fs::read(png_path)
.map_err(|e| PoolError::Rpc(format!("read png {}: {e}", png_path.display())))?;
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
}
pub fn assert_image_rubric(png_path: &Path, name: &str, question: &str) -> Result<(), RubricError> {
let verdict = evaluate_image_rubric(png_path, question)?;
assert_verdict(name, verdict)
}
pub fn evaluate_image_rubric(
png_path: &Path,
question: &str,
) -> Result<RubricVerdict, RubricError> {
evaluate_image_rubric_with_options(png_path, question, default_options())
}
pub fn evaluate_image_rubric_with_options(
png_path: &Path,
question: &str,
opts: RubricOptions,
) -> Result<RubricVerdict, RubricError> {
evaluate_image_rubric_with_config(png_path, question, opts, RubricRunConfig::default())
}
pub fn evaluate_image_rubric_with_config(
png_path: &Path,
question: &str,
opts: RubricOptions,
config: RubricRunConfig,
) -> Result<RubricVerdict, RubricError> {
let bytes = std::fs::read(png_path).map_err(|source| RubricError::ReadPng {
path: png_path.to_path_buf(),
source,
})?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
let text = run_codex_acp_rubric(
&b64,
question,
opts.model
.as_deref()
.map_or(DEFAULT_CODEX_ACP_MODEL, |model| model),
opts.effort
.as_deref()
.map_or(DEFAULT_CODEX_ACP_REASONING_EFFORT, |effort| effort),
opts.system_prompt
.as_deref()
.map_or(DEFAULT_SYSTEM_PROMPT, |system_prompt| system_prompt),
&config,
)?;
parse_verdict(&text).map_err(|source| RubricError::ParseVerdict { text, source })
}
pub fn evaluate_image_rubric_pipeline(
png_path: &Path,
question: &str,
vision_config: &VisionApiConfig,
vision_prompt: &str,
rubric_options: &RubricOptions,
rubric_config: &RubricRunConfig,
) -> Result<RubricVerdict, RubricError> {
let bytes = std::fs::read(png_path).map_err(|source| RubricError::ReadPng {
path: png_path.to_path_buf(),
source,
})?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
let structured =
vision::call_vision_api(&b64, vision_prompt, vision_config).map_err(RubricError::Pool)?;
let system_prompt = rubric_options
.system_prompt
.as_deref()
.map_or(DEFAULT_SYSTEM_PROMPT, |system_prompt| system_prompt);
let rubric_prompt =
format!("{system_prompt}\n\nUI description:\n{structured}\n\nQuestion: {question}");
let mut acp = AcpClient::spawn(
&rubric_config.codex_acp_binary,
&rubric_config.acp_args,
&rubric_config.extra_env,
rubric_config.cwd.as_deref(),
)
.map_err(RubricError::Pool)?;
acp.start_session(rubric_config.cwd.as_deref())
.map_err(RubricError::Pool)?;
let text = acp.prompt_text(&rubric_prompt).map_err(RubricError::Pool)?;
parse_verdict(&text).map_err(|source| RubricError::ParseVerdict { text, source })
}
pub fn parse_verdict(text: &str) -> Result<RubricVerdict, serde_json::Error> {
match serde_json::from_str(text) {
Ok(verdict) => Ok(verdict),
Err(source) => match extract_json_object(text) {
Some(json) => serde_json::from_str(json),
None => Err(source),
},
}
}
fn extract_json_object(text: &str) -> Option<&str> {
let start = text.find('{')?;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (offset, character) in text[start..].char_indices() {
if in_string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_string = false;
}
continue;
}
match character {
'"' => in_string = true,
'{' => depth = depth.saturating_add(1),
'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
let end = start + offset + character.len_utf8();
return Some(&text[start..end]);
}
}
_ => {}
}
}
None
}
fn deserialize_anomalies<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
Ok(values.into_iter().map(anomaly_to_string).collect())
}
fn anomaly_to_string(value: serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text,
serde_json::Value::Object(mut object) => {
let issue = object
.remove("issue")
.and_then(|value| value.as_str().map(str::to_owned));
let fix = object
.remove("fix")
.and_then(|value| value.as_str().map(str::to_owned));
match (issue, fix) {
(Some(issue), Some(fix)) => format!("{issue} Fix: {fix}"),
(Some(issue), None) => issue,
(None, Some(fix)) => fix,
(None, None) => serde_json::Value::Object(object).to_string(),
}
}
other => other.to_string(),
}
}
pub fn assert_verdict(name: &str, verdict: RubricVerdict) -> Result<(), RubricError> {
if verdict.verdict.is_pass() {
Ok(())
} else {
Err(RubricError::Assertion {
name: name.to_string(),
reason: verdict.reason,
anomalies: verdict.anomalies,
})
}
}
pub fn run(cli: Cli) -> anyhow::Result<()> {
cli::run(cli)
}
fn run_codex_acp_rubric(
b64_png: &str,
question: &str,
model: &str,
effort: &str,
system_prompt: &str,
config: &RubricRunConfig,
) -> Result<String, PoolError> {
let args = effective_acp_args(config, model, effort);
let mut acp = AcpClient::spawn(
&config.codex_acp_binary,
args.as_slice(),
&config.extra_env,
config.cwd.as_deref(),
)?;
acp.start_session(config.cwd.as_deref())?;
let prompt = format!("{system_prompt}\n\nQuestion: {question}");
acp.prompt_image(&prompt, b64_png)
}
fn effective_acp_args(config: &RubricRunConfig, model: &str, effort: &str) -> Vec<String> {
if config.acp_args
== build_codex_acp_args(DEFAULT_CODEX_ACP_MODEL, DEFAULT_CODEX_ACP_REASONING_EFFORT)
{
build_codex_acp_args(model, effort)
} else {
config.acp_args.clone()
}
}
#[cfg(test)]
mod tests;