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