1use 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 pub data_dir_env: Option<String>,
59 pub data_dir_arg: Option<String>,
62 #[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 #[serde(default)]
82 pub build: Vec<String>,
83 pub package: Option<String>,
85 #[serde(default)]
86 pub env: BTreeMap<String, String>,
87 #[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 #[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 #[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}