Skip to main content

rightkit_qa/
config.rs

1//! Per-app config: `rightkit-qa.toml`, the TOML replacement for `right-qa.config.mjs`.
2//!
3//! ```toml
4//! app = "genright"
5//! app_root = "."                         # relative to this file
6//! # `evidence_dir` is no longer read (0.5.0): evidence is always written under the managed runs
7//! # root, `<managed root>/runs/<app>/<run-id>/`, never inside the app checkout. An old key is
8//! # ignored.
9//! scenarios = ["tests/scenarios"]        # dirs scanned for *.scenario.toml
10//!
11//! [env]                                  # every process the harness starts
12//! GENRIGHT_NO_KEYCHAIN = "1"
13//!
14//! [engine]
15//! binary = { darwin = "target/debug/genright-engine", windows = "target/debug/genright-engine.exe" }
16//! package = "genright-engine"
17//! build = ["rightkit", "cargo", "build", "-p", "genright-engine"]
18//! data_dir_env = "GENRIGHT_APP_DATA"     # required: a launch with no isolated data dir is refused
19//! # data_dir_arg = "--data-dir"          # or name the dir in the step's arguments
20//! # forbidden_args = ["--use-user-library"]   # flags that select the real library: refused
21//!
22//! [ui]
23//! binaries = { darwin = "target/debug/genright", windows = "target/debug/genright.exe" }
24//! window_label = "main"
25//! mode = "hidden"
26//! feature = "qa-native"
27//! # Finding the dev build (`QaConfig::resolve_ui_binary`, see `binary`): env RIGHTKIT_QA_APP_BINARY,
28//! # then `ui.binary_env`, then the pointer file, then the broker's target root, then `binaries`.
29//! # bin = "genright"                     # default: package, else the `binaries` file stem
30//! # workspace_root = "src-tauri"         # cargo workspace the broker built, relative to app_root
31//! # pointer_file = ".rightkit-qa-app-binary"
32//! # binary_env = "GENRIGHT_APP_BINARY"
33//! ```
34use 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    /// Env var that receives the per-scenario data dir. Required for any engine step: a launch
64    /// that names no isolated data directory is refused (see `userdata::check_engine_launch`).
65    pub data_dir_env: Option<String>,
66    /// Flag the engine takes its data dir under (e.g. `--data-dir`), when a step names the
67    /// isolated directory in its arguments instead of through `data_dir_env`.
68    pub data_dir_arg: Option<String>,
69    /// Flags that select the real library (e.g. `--use-user-library`); a step whose arguments
70    /// contain one is refused.
71    #[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    /// Default for `ui_launch` `focus`: make the window key and focused (off by default).
86    #[serde(default)]
87    pub focus: bool,
88    #[serde(default = "default_feature")]
89    pub feature: String,
90    /// Build command for the app itself (a debug build with the control feature).
91    #[serde(default)]
92    pub build: Vec<String>,
93    /// Cargo target name whose executable the build produces.
94    pub package: Option<String>,
95    #[serde(default)]
96    pub env: BTreeMap<String, String>,
97    /// Source files the guard reads (relative to app_root).
98    #[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    /// Executable name to look for in the broker target root (default: `package`, else the stem of
104    /// the `binaries` entry).
105    pub bin: Option<String>,
106    /// Cargo workspace root, relative to app_root, that the broker built (default: `src-tauri`
107    /// when it exists, and app_root itself).
108    pub workspace_root: Option<String>,
109    /// Pointer file under app_root holding the executable path (default `.rightkit-qa-app-binary`).
110    pub pointer_file: Option<String>,
111    /// The app's own env var for the executable (checked after `RIGHTKIT_QA_APP_BINARY`).
112    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    /// Also mute the macOS system output for the run (restored afterwards).
151    #[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    /// `[tiers]` maps a tier name to the tiers it also selects ("all" = every tier).
179    #[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    /// The dev executable for this app: env override, pointer file, the broker's target root, then
263    /// the `ui.binaries` path (see [`crate::binary`]), always returned as the fixed stash copy
264    /// (`<app root>/.rightkit-qa-bin/...`, copied in place with a kept `.previous`), never a broker
265    /// generation path. `source` names where the build was found. Errors when none of them holds a
266    /// valid file.
267    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    /// [`Self::resolve_ui_binary`] with the broker root and env supplied (tests, odd layouts).
277    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    /// Discovery only, without the stash copy: where the build is now (which may be a broker
286    /// generation path). Launches use [`Self::resolve_ui_binary`], never this path.
287    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}