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