1use crate::client::{Client, Endpoint};
12use crate::i18n::Lang;
13use crate::probes::{self, Cancel, Ctx, Depth, Event, Pace, Selection};
14use crate::report::Report;
15use crate::util::Rng;
16use crate::verdict;
17use anyhow::Result;
18
19#[derive(Clone)]
21pub struct RunConfig {
22 pub endpoint: Endpoint,
23 pub claimed_model: Option<String>,
26 pub depth: Depth,
27 pub lang: Lang,
28 pub selection: Selection,
29 pub seed: Option<u64>,
32 pub http: Option<reqwest::Client>,
34 pub pace: Option<Pace>,
36}
37
38impl RunConfig {
39 pub fn new(endpoint: Endpoint) -> Self {
40 RunConfig {
41 endpoint,
42 claimed_model: None,
43 depth: Depth::Balanced,
44 lang: Lang::En,
45 selection: Selection::all(),
46 seed: None,
47 http: None,
48 pace: None,
49 }
50 }
51
52 pub fn model_only(mut self) -> Self {
54 self.selection = Selection::model_only();
55 self
56 }
57
58 pub fn depth(mut self, d: Depth) -> Self {
59 self.depth = d;
60 self
61 }
62
63 pub fn lang(mut self, l: Lang) -> Self {
64 self.lang = l;
65 self
66 }
67
68 pub fn seed(mut self, s: u64) -> Self {
69 self.seed = Some(s);
70 self
71 }
72
73 pub fn claimed_model(mut self, m: impl Into<String>) -> Self {
74 self.claimed_model = Some(m.into());
75 self
76 }
77
78 pub fn http(mut self, c: reqwest::Client) -> Self {
79 self.http = Some(c);
80 self
81 }
82
83 pub fn pace(mut self, min: std::time::Duration, max: std::time::Duration) -> Self {
85 self.pace = Some(Pace { min, max });
86 self
87 }
88}
89
90pub async fn run(
95 cfg: RunConfig,
96 cancel: &Cancel,
97 on_event: &mut (dyn FnMut(Event<'_>) + Send),
98) -> Result<Report> {
99 let started_at = crate::util::iso8601_utc();
100 let t0 = crate::util::now_ms();
101
102 let seed = cfg.seed.unwrap_or_else(|| {
103 (crate::util::now_ms() as u64) ^ 0x9E37_79B9_7F4A_7C15
105 });
106 let claimed_model = cfg
107 .claimed_model
108 .clone()
109 .unwrap_or_else(|| cfg.endpoint.model.clone());
110 let protocol = cfg.endpoint.protocol;
111 let model = cfg.endpoint.model.clone();
112 let base_url = cfg.endpoint.base_url.clone();
113 let host = cfg.endpoint.host();
114
115 let client = match cfg.http.clone() {
116 Some(http) => Client::with_http(cfg.endpoint.clone(), http),
117 None => Client::new(cfg.endpoint.clone())?,
118 };
119 let ctx = Ctx::with_rng(
120 client,
121 cfg.depth,
122 cfg.lang,
123 claimed_model.clone(),
124 Rng::from_seed(seed),
125 );
126
127 let specs = cfg.selection.resolve();
128 let steps: Vec<String> = specs
133 .iter()
134 .map(|s| s.id.to_string())
135 .chain(
136 cfg.selection
137 .resolve_extra()
138 .iter()
139 .map(|p| p.id().to_string()),
140 )
141 .collect();
142 let extra = cfg.selection.resolve_extra();
143 let results = probes::run_with_extra(&ctx, &specs, &extra, cancel, cfg.pace, on_event).await;
144
145 let l = cfg.lang;
146 let identity = verdict::build_identity(&results, &claimed_model, l);
147 let billing = verdict::build_billing(&results, &model, l);
148 let channel = verdict::build_channel(&results, l);
149 let v = verdict::decide(&results, &identity, &billing, &channel, protocol, l);
150 let perf = probes::perf::summarize(&ctx.perf.lock().unwrap());
151
152 let skipped = results
153 .iter()
154 .filter(|r| {
155 matches!(
156 r.status,
157 crate::report::Status::Skip | crate::report::Status::Error
158 )
159 })
160 .map(|r| t!(l, "{} ({}) — {}", "{}({}):{}", r.label, r.id, r.summary))
161 .collect();
162
163 Ok(Report {
164 schema_version: crate::report::schema_version(),
165 tool_version: env!("CARGO_PKG_VERSION").to_string(),
166 lang: l,
167 started_at,
168 finished_at: crate::util::iso8601_utc(),
169 duration_ms: (crate::util::now_ms() - t0) as u64,
170 host,
171 base_url,
172 protocol,
173 model,
174 claimed_model,
175 depth: cfg.depth.as_str().to_string(),
176 seed,
177 steps,
178 request_count: ctx.client.requests(),
179 results,
180 verdict: v,
181 identity,
182 billing,
183 channel,
184 perf,
185 skipped,
186 })
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
198 fn run_future_is_send() {
199 fn assert_send<T: Send>(_: T) {}
200 let cfg = RunConfig::new(Endpoint {
201 base_url: "https://example.invalid".into(),
202 model: "m".into(),
203 ..Default::default()
204 });
205 let cancel = Cancel::new();
206 let mut sink = |_: Event<'_>| {};
207 assert_send(run(cfg, &cancel, &mut sink));
208 }
209
210 #[test]
211 fn model_only_drops_endpoint_steps_but_keeps_preflight() {
212 let specs = Selection::model_only().resolve();
213 let ids: Vec<&str> = specs.iter().map(|s| s.id).collect();
214 assert!(
215 ids.contains(&"preflight"),
216 "the run is meaningless without it"
217 );
218 assert!(ids.contains(&"identity"));
219 assert!(ids.contains(&"perf"));
220 assert!(!ids.contains(&"billing"));
223 assert!(!ids.contains(&"channel"));
224 assert!(!ids.contains(&"missing_auth"));
225 }
226
227 #[test]
228 fn skip_wins_over_an_explicit_include() {
229 let sel = Selection {
230 only: vec!["identity".into(), "perf".into()],
231 skip: vec!["perf".into()],
232 ..Default::default()
233 };
234 let ids: Vec<&str> = sel.resolve().iter().map(|s| s.id).collect();
235 assert!(ids.contains(&"identity"));
236 assert!(!ids.contains(&"perf"));
237 }
238}