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//! # Legacy compatibility key; harness/suite evidence is always under managed storage.
7//! evidence_dir = ".cache/rightkit-qa"
8//! scenarios = ["tests/scenarios"]        # dirs scanned for *.scenario.toml
9//!
10//! [env]                                  # every process the harness starts
11//! GENRIGHT_NO_KEYCHAIN = "1"
12//!
13//! [engine]
14//! binary = { darwin = "target/debug/genright-engine", windows = "target/debug/genright-engine.exe" }
15//! package = "genright-engine"
16//! build = ["rightkit", "cargo", "build", "-p", "genright-engine"]
17//! data_dir_env = "GENRIGHT_APP_DATA"     # required: a launch with no isolated data dir is refused
18//! # data_dir_arg = "--data-dir"          # or name the dir in the step's arguments
19//! # forbidden_args = ["--use-user-library"]   # flags that select the real library: refused
20//!
21//! [ui]
22//! binaries = { darwin = "target/debug/genright", windows = "target/debug/genright.exe" }
23//! window_label = "main"
24//! mode = "hidden"
25//! feature = "qa-native"
26//! ```
27use crate::util::{err, platform, require_text, Error, Result};
28use serde::Deserialize;
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, Clone, Deserialize, Default)]
33pub struct PerPlatform {
34    #[serde(alias = "macos")]
35    pub darwin: Option<String>,
36    #[serde(alias = "win32")]
37    pub windows: Option<String>,
38}
39
40impl PerPlatform {
41    pub fn for_current(&self) -> Result<Option<&str>> {
42        Ok(match platform()? {
43            "darwin" => self.darwin.as_deref(),
44            _ => self.windows.as_deref(),
45        })
46    }
47}
48
49#[derive(Debug, Clone, Deserialize, Default)]
50pub struct EngineConfig {
51    #[serde(default)]
52    pub binary: PerPlatform,
53    pub package: Option<String>,
54    #[serde(default)]
55    pub build: Vec<String>,
56    /// Env var that receives the per-scenario data dir. Required for any engine step: a launch
57    /// that names no isolated data directory is refused (see `userdata::check_engine_launch`).
58    pub data_dir_env: Option<String>,
59    /// Flag the engine takes its data dir under (e.g. `--data-dir`), when a step names the
60    /// isolated directory in its arguments instead of through `data_dir_env`.
61    pub data_dir_arg: Option<String>,
62    /// Flags that select the real library (e.g. `--use-user-library`); a step whose arguments
63    /// contain one is refused.
64    #[serde(default)]
65    pub forbidden_args: Vec<String>,
66    #[serde(default)]
67    pub env: BTreeMap<String, String>,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71pub struct UiConfig {
72    #[serde(default)]
73    pub binaries: PerPlatform,
74    #[serde(default = "default_label")]
75    pub window_label: String,
76    #[serde(default = "default_mode")]
77    pub mode: String,
78    #[serde(default = "default_feature")]
79    pub feature: String,
80    /// Build command for the app itself (a debug build with the control feature).
81    #[serde(default)]
82    pub build: Vec<String>,
83    /// Cargo target name whose executable the build produces.
84    pub package: Option<String>,
85    #[serde(default)]
86    pub env: BTreeMap<String, String>,
87    /// Source files the guard reads (relative to app_root).
88    #[serde(default)]
89    pub rust_sources: Vec<String>,
90    pub cargo_toml: Option<String>,
91    pub package_json: Option<String>,
92    pub startup_timeout_s: Option<u64>,
93}
94
95impl Default for UiConfig {
96    fn default() -> Self {
97        Self {
98            binaries: PerPlatform::default(),
99            window_label: default_label(),
100            mode: default_mode(),
101            feature: default_feature(),
102            build: vec![],
103            package: None,
104            env: BTreeMap::new(),
105            rust_sources: vec![],
106            cargo_toml: None,
107            package_json: None,
108            startup_timeout_s: None,
109        }
110    }
111}
112
113fn default_label() -> String {
114    "main".into()
115}
116fn default_mode() -> String {
117    "hidden".into()
118}
119fn default_feature() -> String {
120    "qa-native".into()
121}
122
123#[derive(Debug, Clone, Deserialize, Default)]
124pub struct AudioConfig {
125    /// Also mute the macOS system output for the run (restored afterwards).
126    #[serde(default)]
127    pub system_mute: bool,
128}
129
130#[derive(Debug, Clone, Deserialize, Default)]
131pub struct LockConfig {
132    pub timeout_s: Option<u64>,
133    pub stale_s: Option<u64>,
134}
135
136#[derive(Debug, Clone, Deserialize)]
137struct Raw {
138    app: String,
139    #[serde(default = "dot")]
140    app_root: String,
141    evidence_dir: Option<String>,
142    #[serde(default)]
143    scenarios: Vec<String>,
144    #[serde(default)]
145    env: BTreeMap<String, String>,
146    #[serde(default)]
147    engine: EngineConfig,
148    #[serde(default)]
149    ui: UiConfig,
150    #[serde(default)]
151    audio: AudioConfig,
152    #[serde(default)]
153    lock: LockConfig,
154    /// `[tiers]` maps a tier name to the tiers it also selects ("all" = every tier).
155    #[serde(default)]
156    default_tier: Option<String>,
157}
158
159fn dot() -> String {
160    ".".into()
161}
162
163#[derive(Debug, Clone)]
164pub struct QaConfig {
165    pub app: String,
166    pub app_root: PathBuf,
167    pub evidence_root: PathBuf,
168    pub scenario_dirs: Vec<PathBuf>,
169    pub env: BTreeMap<String, String>,
170    pub engine: EngineConfig,
171    pub ui: UiConfig,
172    pub audio: AudioConfig,
173    pub lock: LockConfig,
174    pub default_tier: String,
175    pub config_path: PathBuf,
176}
177
178pub const TIERS: [&str; 3] = ["fast", "local-models", "paid"];
179
180impl QaConfig {
181    pub fn load(path: &Path) -> Result<Self> {
182        let text = std::fs::read_to_string(path)
183            .map_err(|e| Error(format!("cannot read {}: {e}", path.display())))?;
184        Self::parse(&text, path)
185    }
186
187    pub fn parse(text: &str, path: &Path) -> Result<Self> {
188        let raw: Raw = toml::from_str(text)?;
189        let app = require_text(&raw.app, "app")?;
190        if !app
191            .chars()
192            .next()
193            .map(|c| c.is_ascii_lowercase())
194            .unwrap_or(false)
195            || !app
196                .chars()
197                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
198        {
199            return err("app must be a lowercase app key");
200        }
201        let base = path.parent().unwrap_or(Path::new("."));
202        let app_root = base.join(require_text(&raw.app_root, "app_root")?);
203        let app_root = app_root.canonicalize().unwrap_or(app_root);
204        if raw.ui.window_label.trim().is_empty() {
205            return err("ui.window_label is required");
206        }
207        crate::control::Mode::parse(&raw.ui.mode)?;
208        let default_tier = raw.default_tier.unwrap_or_else(|| "fast".into());
209        validate_tier_list(&default_tier)?;
210        Ok(Self {
211            evidence_root: app_root
212                .join(raw.evidence_dir.as_deref().unwrap_or(".cache/rightkit-qa")),
213            scenario_dirs: raw.scenarios.iter().map(|s| app_root.join(s)).collect(),
214            app,
215            app_root,
216            env: raw.env,
217            engine: raw.engine,
218            ui: raw.ui,
219            audio: raw.audio,
220            lock: raw.lock,
221            default_tier,
222            config_path: path.to_path_buf(),
223        })
224    }
225
226    pub fn engine_binary(&self) -> Result<Option<PathBuf>> {
227        Ok(self
228            .engine
229            .binary
230            .for_current()?
231            .map(|p| self.app_root.join(p)))
232    }
233    pub fn ui_binary(&self) -> Result<Option<PathBuf>> {
234        Ok(self
235            .ui
236            .binaries
237            .for_current()?
238            .map(|p| self.app_root.join(p)))
239    }
240}
241
242pub fn validate_tier_list(list: &str) -> Result<()> {
243    for t in list.split(',').map(str::trim) {
244        if t != "all" && !TIERS.contains(&t) {
245            return err(format!("unknown tier '{t}' (fast|local-models|paid|all)"));
246        }
247    }
248    Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    const SAMPLE: &str = r#"
256app = "genright"
257[env]
258GENRIGHT_NO_KEYCHAIN = "1"
259[engine]
260binary = { darwin = "target/debug/genright-engine", win32 = "target/debug/genright-engine.exe" }
261data_dir_env = "GENRIGHT_APP_DATA"
262[ui]
263binaries = { macos = "a", windows = "b" }
264mode = "background"
265"#;
266
267    #[test]
268    fn parses_aliases_and_defaults() {
269        let c = QaConfig::parse(SAMPLE, Path::new("/tmp/x/rightkit-qa.toml")).unwrap();
270        assert_eq!(c.app, "genright");
271        assert_eq!(c.ui.window_label, "main");
272        assert_eq!(
273            c.engine.binary.windows.as_deref(),
274            Some("target/debug/genright-engine.exe")
275        );
276        assert_eq!(c.ui.binaries.darwin.as_deref(), Some("a"));
277        assert_eq!(c.default_tier, "fast");
278    }
279
280    #[test]
281    fn rejects_bad_app_key_mode_and_tier() {
282        assert!(QaConfig::parse("app = \"GenRight\"", Path::new("/tmp/c.toml")).is_err());
283        assert!(QaConfig::parse(
284            "app = \"x\"\n[ui]\nmode = \"loud\"",
285            Path::new("/tmp/c.toml")
286        )
287        .is_err());
288        assert!(validate_tier_list("fast,bogus").is_err());
289        assert!(validate_tier_list("fast,paid").is_ok());
290    }
291}