1use std::path::PathBuf;
4
5use anyhow::{Context as _, Result, anyhow};
6use clap::{Parser, Subcommand};
7
8mod audit;
9mod static_server;
10#[cfg(test)]
11mod tests;
12
13#[cfg(test)]
14use audit::RubricReport;
15use audit::run_audit;
16pub use audit::{AuditReport, AuditStatus};
17use static_server::StaticServer;
18#[cfg(test)]
19use static_server::{content_type, resolve_static_path};
20
21#[derive(Debug, Parser)]
23#[command(name = "visual-rubric")]
24pub struct Cli {
25 #[command(subcommand)]
26 command: Option<Commands>,
27 #[command(flatten)]
28 image: LegacyImageArgs,
29}
30
31#[derive(Debug, Subcommand)]
32enum Commands {
33 Image(ImageArgs),
35 Audit(AuditArgs),
37 Serve(ServeArgs),
39}
40
41#[derive(Clone, Debug, Parser)]
42struct LegacyImageArgs {
43 #[arg(long)]
44 image: Option<PathBuf>,
45 #[arg(long)]
46 question: Option<String>,
47 #[arg(long)]
48 system_prompt: Option<String>,
49 #[arg(long)]
50 model: Option<String>,
51 #[arg(long)]
52 effort: Option<String>,
53 #[arg(long)]
54 codex_acp: Option<PathBuf>,
55 #[arg(long, default_value = "vnc-screenshot")]
56 name: String,
57 #[arg(long)]
58 json: bool,
59}
60
61#[derive(Clone, Debug, Parser)]
62struct ImageArgs {
63 #[arg(long)]
64 image: PathBuf,
65 #[arg(long)]
66 question: String,
67 #[arg(long)]
68 system_prompt: Option<String>,
69 #[arg(long)]
70 model: Option<String>,
71 #[arg(long)]
72 effort: Option<String>,
73 #[arg(long)]
74 codex_acp: Option<PathBuf>,
75 #[arg(long, default_value = "screenshot")]
76 name: String,
77 #[arg(long)]
78 json: bool,
79}
80
81#[derive(Clone, Debug, Parser)]
82struct AuditArgs {
83 #[arg(long)]
85 root: PathBuf,
86 #[arg(long, default_value = "/")]
88 path: String,
89 #[arg(long, default_value = "target/visual-rubric")]
91 screenshots: PathBuf,
92 #[arg(long, default_value = "target/visual-rubric/report.json")]
94 report: PathBuf,
95 #[arg(long, env = "VISUAL_RUBRIC_BROWSER", default_value = "chromium")]
97 browser: PathBuf,
98 #[arg(long = "browser-arg")]
100 browser_args: Vec<String>,
101 #[arg(long, default_value_t = 0)]
103 wait_ms: u64,
104 #[arg(long)]
106 device_scale_factor: Option<f32>,
107 #[arg(long, default_value_t = 0)]
109 capture_retries: u32,
110 #[arg(long)]
112 fail_on_rubric: bool,
113 #[arg(long = "viewport")]
115 viewports: Vec<ViewportArg>,
116 #[arg(long)]
117 question: String,
118 #[arg(long)]
119 system_prompt: Option<String>,
120 #[arg(long)]
121 model: Option<String>,
122 #[arg(long)]
123 effort: Option<String>,
124 #[arg(long)]
125 codex_acp: Option<PathBuf>,
126 #[arg(long)]
128 fake_pass: bool,
129 #[arg(long)]
131 skip_ai: bool,
132}
133
134#[derive(Clone, Debug, Parser)]
135struct ServeArgs {
136 #[arg(long)]
137 root: PathBuf,
138 #[arg(long, default_value_t = 1111)]
139 port: u16,
140}
141
142#[derive(Clone, Debug)]
143struct ViewportArg {
144 name: String,
145 width: u32,
146 height: u32,
147}
148
149pub fn run(cli: Cli) -> Result<()> {
155 match cli.command {
156 Some(Commands::Image(args)) => run_image(args),
157 Some(Commands::Audit(args)) => run_audit(args),
158 Some(Commands::Serve(args)) => run_serve(args),
159 None => run_image(cli.image.try_into()?),
160 }
161}
162
163fn run_image(args: ImageArgs) -> Result<()> {
164 let verdict = evaluate_image(&args)?;
165 if args.json {
166 println!("{}", serde_json::to_string(&verdict)?);
167 return Ok(());
168 }
169 crate::assert_verdict(&args.name, verdict)
170 .map(|()| println!("visual rubric passed"))
171 .map_err(|error| anyhow!(error))
172}
173
174fn run_serve(args: ServeArgs) -> Result<()> {
175 let server = StaticServer::start(args.root, args.port)?;
176 println!("{}", server.base_url());
177 server.wait_forever()
178}
179
180fn evaluate_image(args: &ImageArgs) -> Result<crate::RubricVerdict> {
181 let options = crate::RubricOptions {
182 model: args.model.clone(),
183 effort: args.effort.clone().map(Into::into),
184 system_prompt: args.system_prompt.clone(),
185 };
186 if let Some(codex_acp) = &args.codex_acp {
187 let pool = crate::RubricPool::new(crate::PoolConfig {
188 workers: 1,
189 codex_acp_binary: codex_acp.clone(),
190 default_options: merge_with_defaults(options),
191 ..crate::PoolConfig::default()
192 })?;
193 let verdict = pool.submit(&args.image, &args.question, crate::RubricOptions::default())?;
194 let _ = pool.shutdown();
195 Ok(verdict)
196 } else {
197 crate::evaluate_image_rubric_with_options(&args.image, &args.question, options)
198 .map_err(|error| anyhow!(error))
199 }
200}
201
202fn merge_with_defaults(mut options: crate::RubricOptions) -> crate::RubricOptions {
203 let defaults = crate::default_options();
204 if options.model.is_none() {
205 options.model = defaults.model;
206 }
207 if options.effort.is_none() {
208 options.effort = defaults.effort;
209 }
210 if options.system_prompt.is_none() {
211 options.system_prompt = defaults.system_prompt;
212 }
213 options
214}
215
216impl TryFrom<LegacyImageArgs> for ImageArgs {
217 type Error = anyhow::Error;
218
219 fn try_from(value: LegacyImageArgs) -> Result<Self> {
220 Ok(Self {
221 image: value.image.context("--image is required")?,
222 question: value.question.context("--question is required")?,
223 system_prompt: value.system_prompt,
224 model: value.model,
225 effort: value.effort,
226 codex_acp: value.codex_acp,
227 name: value.name,
228 json: value.json,
229 })
230 }
231}
232
233impl std::str::FromStr for ViewportArg {
234 type Err = anyhow::Error;
235
236 fn from_str(value: &str) -> Result<Self> {
237 let (name, size) = value
238 .split_once('=')
239 .context("viewport must be name=WIDTHxHEIGHT")?;
240 let (width, height) = size
241 .split_once('x')
242 .context("viewport size must be WIDTHxHEIGHT")?;
243 Ok(Self {
244 name: name.to_string(),
245 width: width.parse().context("viewport width must be an integer")?,
246 height: height
247 .parse()
248 .context("viewport height must be an integer")?,
249 })
250 }
251}