1use crate::util::{err, platform, require_text, Error, Result};
35use serde::Deserialize;
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, Deserialize, Default)]
40pub struct PerPlatform {
41 #[serde(alias = "macos")]
42 pub darwin: Option<String>,
43 #[serde(alias = "win32")]
44 pub windows: Option<String>,
45}
46
47impl PerPlatform {
48 pub fn for_current(&self) -> Result<Option<&str>> {
49 Ok(match platform()? {
50 "darwin" => self.darwin.as_deref(),
51 _ => self.windows.as_deref(),
52 })
53 }
54}
55
56#[derive(Debug, Clone, Deserialize, Default)]
57pub struct EngineConfig {
58 #[serde(default)]
59 pub binary: PerPlatform,
60 pub package: Option<String>,
61 #[serde(default)]
62 pub build: Vec<String>,
63 pub data_dir_env: Option<String>,
66 pub data_dir_arg: Option<String>,
69 #[serde(default)]
72 pub forbidden_args: Vec<String>,
73 #[serde(default)]
74 pub env: BTreeMap<String, String>,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78pub struct UiConfig {
79 #[serde(default)]
80 pub binaries: PerPlatform,
81 #[serde(default = "default_label")]
82 pub window_label: String,
83 #[serde(default = "default_mode")]
84 pub mode: String,
85 #[serde(default)]
87 pub focus: bool,
88 #[serde(default = "default_feature")]
89 pub feature: String,
90 #[serde(default)]
92 pub build: Vec<String>,
93 pub package: Option<String>,
95 #[serde(default)]
96 pub env: BTreeMap<String, String>,
97 #[serde(default)]
99 pub rust_sources: Vec<String>,
100 pub cargo_toml: Option<String>,
101 pub package_json: Option<String>,
102 pub startup_timeout_s: Option<u64>,
103 pub bin: Option<String>,
106 pub workspace_root: Option<String>,
109 pub pointer_file: Option<String>,
111 pub binary_env: Option<String>,
113}
114
115impl Default for UiConfig {
116 fn default() -> Self {
117 Self {
118 binaries: PerPlatform::default(),
119 window_label: default_label(),
120 mode: default_mode(),
121 focus: false,
122 feature: default_feature(),
123 build: vec![],
124 package: None,
125 env: BTreeMap::new(),
126 rust_sources: vec![],
127 cargo_toml: None,
128 package_json: None,
129 startup_timeout_s: None,
130 bin: None,
131 workspace_root: None,
132 pointer_file: None,
133 binary_env: None,
134 }
135 }
136}
137
138fn default_label() -> String {
139 "main".into()
140}
141fn default_mode() -> String {
142 "hidden".into()
143}
144fn default_feature() -> String {
145 "qa-native".into()
146}
147
148#[derive(Debug, Clone, Deserialize, Default)]
149pub struct AudioConfig {
150 #[serde(default)]
152 pub system_mute: bool,
153}
154
155#[derive(Debug, Clone, Deserialize, Default)]
156pub struct LockConfig {
157 pub timeout_s: Option<u64>,
158 pub stale_s: Option<u64>,
159}
160
161#[derive(Debug, Clone, Deserialize)]
162struct Raw {
163 app: String,
164 #[serde(default = "dot")]
165 app_root: String,
166 #[serde(default)]
167 scenarios: Vec<String>,
168 #[serde(default)]
169 env: BTreeMap<String, String>,
170 #[serde(default)]
171 engine: EngineConfig,
172 #[serde(default)]
173 ui: UiConfig,
174 #[serde(default)]
175 audio: AudioConfig,
176 #[serde(default)]
177 lock: LockConfig,
178 #[serde(default)]
180 default_tier: Option<String>,
181}
182
183fn dot() -> String {
184 ".".into()
185}
186
187#[derive(Debug, Clone)]
188pub struct QaConfig {
189 pub app: String,
190 pub app_root: PathBuf,
191 pub scenario_dirs: Vec<PathBuf>,
192 pub env: BTreeMap<String, String>,
193 pub engine: EngineConfig,
194 pub ui: UiConfig,
195 pub audio: AudioConfig,
196 pub lock: LockConfig,
197 pub default_tier: String,
198 pub config_path: PathBuf,
199}
200
201pub const TIERS: [&str; 3] = ["fast", "local-models", "paid"];
202
203impl QaConfig {
204 pub fn load(path: &Path) -> Result<Self> {
205 let text = std::fs::read_to_string(path)
206 .map_err(|e| Error(format!("cannot read {}: {e}", path.display())))?;
207 Self::parse(&text, path)
208 }
209
210 pub fn parse(text: &str, path: &Path) -> Result<Self> {
211 let raw: Raw = toml::from_str(text)?;
212 let app = require_text(&raw.app, "app")?;
213 if !app
214 .chars()
215 .next()
216 .map(|c| c.is_ascii_lowercase())
217 .unwrap_or(false)
218 || !app
219 .chars()
220 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
221 {
222 return err("app must be a lowercase app key");
223 }
224 let base = path.parent().unwrap_or(Path::new("."));
225 let app_root = base.join(require_text(&raw.app_root, "app_root")?);
226 let app_root = crate::util::canonical_plain(&app_root).unwrap_or(app_root);
227 if raw.ui.window_label.trim().is_empty() {
228 return err("ui.window_label is required");
229 }
230 crate::control::Mode::parse(&raw.ui.mode)?;
231 let default_tier = raw.default_tier.unwrap_or_else(|| "fast".into());
232 validate_tier_list(&default_tier)?;
233 Ok(Self {
234 scenario_dirs: raw.scenarios.iter().map(|s| app_root.join(s)).collect(),
235 app,
236 app_root,
237 env: raw.env,
238 engine: raw.engine,
239 ui: raw.ui,
240 audio: raw.audio,
241 lock: raw.lock,
242 default_tier,
243 config_path: path.to_path_buf(),
244 })
245 }
246
247 pub fn engine_binary(&self) -> Result<Option<PathBuf>> {
248 Ok(self
249 .engine
250 .binary
251 .for_current()?
252 .map(|p| self.app_root.join(p)))
253 }
254 pub fn ui_binary(&self) -> Result<Option<PathBuf>> {
255 Ok(self
256 .ui
257 .binaries
258 .for_current()?
259 .map(|p| self.app_root.join(p)))
260 }
261
262 pub fn resolve_ui_binary(&self) -> Result<crate::binary::ResolvedBinary> {
268 self.resolve_ui_binary_stashed_with(
269 Some(crate::binary::broker_root_of(
270 &crate::storage::managed_root(None),
271 )),
272 &|k| std::env::var(k).ok(),
273 )
274 }
275
276 pub fn resolve_ui_binary_stashed_with(
278 &self,
279 broker_root: Option<PathBuf>,
280 env: &dyn Fn(&str) -> Option<String>,
281 ) -> Result<crate::binary::ResolvedBinary> {
282 self.with_binary_query(broker_root, env, crate::binary::resolve_stashed)
283 }
284
285 pub fn resolve_ui_binary_with(
288 &self,
289 broker_root: Option<PathBuf>,
290 env: &dyn Fn(&str) -> Option<String>,
291 ) -> Result<crate::binary::ResolvedBinary> {
292 self.with_binary_query(broker_root, env, crate::binary::resolve)
293 }
294
295 fn with_binary_query(
296 &self,
297 broker_root: Option<PathBuf>,
298 env: &dyn Fn(&str) -> Option<String>,
299 f: impl FnOnce(&crate::binary::BinaryQuery) -> Result<crate::binary::ResolvedBinary>,
300 ) -> Result<crate::binary::ResolvedBinary> {
301 let rel = self.ui.binaries.for_current()?;
302 let bin = self
303 .ui
304 .bin
305 .clone()
306 .or_else(|| self.ui.package.clone())
307 .or_else(|| {
308 rel.and_then(|r| Path::new(r).file_stem())
309 .map(|s| s.to_string_lossy().into_owned())
310 });
311 let Some(bin) = bin else {
312 return err("set [ui].bin, [ui].package or [ui].binaries to name the executable");
313 };
314 let workspace_roots = match &self.ui.workspace_root {
315 Some(w) => vec![self.app_root.join(w)],
316 None => vec![self.app_root.join("src-tauri"), self.app_root.clone()],
317 };
318 f(&crate::binary::BinaryQuery {
319 bin: &bin,
320 app_root: &self.app_root,
321 workspace_roots,
322 broker_root,
323 extra_env: self.ui.binary_env.as_deref(),
324 pointer_file: self.ui.pointer_file.as_deref(),
325 app_relative: rel,
326 env,
327 })
328 }
329}
330
331pub fn validate_tier_list(list: &str) -> Result<()> {
332 for t in list.split(',').map(str::trim) {
333 if t != "all" && !TIERS.contains(&t) {
334 return err(format!("unknown tier '{t}' (fast|local-models|paid|all)"));
335 }
336 }
337 Ok(())
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 const SAMPLE: &str = r#"
345app = "genright"
346[env]
347GENRIGHT_NO_KEYCHAIN = "1"
348[engine]
349binary = { darwin = "target/debug/genright-engine", win32 = "target/debug/genright-engine.exe" }
350data_dir_env = "GENRIGHT_APP_DATA"
351[ui]
352binaries = { macos = "a", windows = "b" }
353mode = "background"
354"#;
355
356 #[test]
357 fn parses_aliases_and_defaults() {
358 let c = QaConfig::parse(SAMPLE, Path::new("/tmp/x/rightkit-qa.toml")).unwrap();
359 assert_eq!(c.app, "genright");
360 assert_eq!(c.ui.window_label, "main");
361 assert_eq!(
362 c.engine.binary.windows.as_deref(),
363 Some("target/debug/genright-engine.exe")
364 );
365 assert_eq!(c.ui.binaries.darwin.as_deref(), Some("a"));
366 assert_eq!(c.default_tier, "fast");
367 }
368
369 #[test]
370 fn ui_focus_defaults_off_and_parses() {
371 let c = QaConfig::parse(SAMPLE, Path::new("/tmp/x/rightkit-qa.toml")).unwrap();
372 assert!(!c.ui.focus);
373 let on = SAMPLE.replace("mode = \"background\"", "mode = \"hidden\"\nfocus = true");
374 let c = QaConfig::parse(&on, Path::new("/tmp/x/rightkit-qa.toml")).unwrap();
375 assert!(c.ui.focus);
376 }
377
378 #[test]
379 fn resolves_ui_binary_from_config_without_the_real_broker() {
380 let base = std::env::temp_dir().join(format!("rkqa-cfg-{}", crate::util::new_id()));
381 let exe = if cfg!(windows) { "myapp.exe" } else { "myapp" };
382 let built = base.join("target/debug").join(exe);
383 std::fs::create_dir_all(built.parent().unwrap()).unwrap();
384 std::fs::write(&built, crate::binary::QA_CONTROL_MARKER.as_bytes()).unwrap();
385 let toml = format!(
386 "app = \"myapp\"
387[ui]
388binaries = {{ macos = \"target/debug/{exe}\", windows = \"target/debug/{exe}\" }}
389"
390 );
391 let c = QaConfig::parse(&toml, &base.join("rightkit-qa.toml")).unwrap();
392 let none = |_: &str| None;
393 let got = c.resolve_ui_binary_with(None, &none).unwrap();
394 assert_eq!(got.source, crate::binary::BinarySource::AppRoot);
395 assert!(got.path.ends_with(exe));
396 let env =
397 |k: &str| (k == "RIGHTKIT_QA_APP_BINARY").then(|| built.to_string_lossy().into_owned());
398 let got = c.resolve_ui_binary_with(None, &env).unwrap();
399 assert!(matches!(got.source, crate::binary::BinarySource::Env(_)));
400 }
401
402 #[test]
403 fn rejects_bad_app_key_mode_and_tier() {
404 assert!(QaConfig::parse("app = \"GenRight\"", Path::new("/tmp/c.toml")).is_err());
405 assert!(QaConfig::parse(
406 "app = \"x\"\n[ui]\nmode = \"loud\"",
407 Path::new("/tmp/c.toml")
408 )
409 .is_err());
410 assert!(validate_tier_list("fast,bogus").is_err());
411 assert!(validate_tier_list("fast,paid").is_ok());
412 }
413}