1use 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 #[serde(default)]
71 pub build: Vec<String>,
72 pub package: Option<String>,
74 #[serde(default)]
75 pub env: BTreeMap<String, String>,
76 #[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 #[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 #[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}