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