1use crate::util::{err, Result};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Backend {
12 Control,
13 Wdio,
14}
15
16impl Backend {
17 pub fn parse(s: &str) -> Option<Self> {
18 match s {
19 "control" => Some(Backend::Control),
20 "wdio" => Some(Backend::Wdio),
21 _ => None,
22 }
23 }
24 fn dependency(self) -> &'static str {
25 match self {
26 Backend::Control => "rightkit-control",
27 Backend::Wdio => "tauri-plugin-wdio-webdriver",
28 }
29 }
30 fn registration(self) -> &'static str {
31 match self {
32 Backend::Control => "rightkit_control::embedded::Control",
33 Backend::Wdio => "tauri_plugin_wdio_webdriver::init",
34 }
35 }
36 fn runtime_flag(self) -> &'static str {
37 match self {
38 Backend::Control => "RIGHTKIT_CONTROL_SERVICE",
39 Backend::Wdio => "RIGHTKIT_QA_NATIVE",
40 }
41 }
42}
43
44pub struct ContractInput<'a> {
45 pub cargo_toml: &'a str,
46 pub rust_source: &'a str,
47 pub package_scripts: &'a BTreeMap<String, String>,
48 pub backend: Backend,
49}
50
51fn dependency_body(cargo: &str, name: &str) -> Option<String> {
53 let mut search = 0;
54 while let Some(i) = cargo[search..].find(name) {
55 let at = search + i;
56 search = at + name.len();
57 let prev = if at == 0 {
58 b' '
59 } else {
60 cargo.as_bytes()[at - 1]
61 };
62 let before_ok = !prev.is_ascii_alphanumeric() && prev != b'-' && prev != b'_';
63 let rest = cargo[at + name.len()..].trim_start();
64 if before_ok && rest.starts_with('=') {
65 let rest = rest[1..].trim_start();
66 if let Some(body) = rest.strip_prefix('{') {
67 return body.find('}').map(|e| body[..e].to_string());
68 }
69 return Some(String::new());
70 }
71 }
72 cargo.find(&format!("[dependencies.{name}]")).map(|i| {
73 let tail = &cargo[i..];
74 let end = tail[1..].find("\n[").map(|e| e + 1).unwrap_or(tail.len());
75 tail[..end].to_string()
76 })
77}
78
79fn feature_list(cargo: &str, feature: &str) -> String {
80 let mut search = 0;
81 while let Some(i) = cargo[search..].find(feature) {
82 let at = search + i;
83 search = at + feature.len();
84 let rest = cargo[at + feature.len()..].trim_start();
85 if let Some(r) = rest.strip_prefix('=') {
86 if let Some(body) = r.trim_start().strip_prefix('[') {
87 if let Some(e) = body.find(']') {
88 return body[..e].to_string();
89 }
90 }
91 }
92 }
93 String::new()
94}
95
96pub fn validate(input: &ContractInput) -> Vec<String> {
97 let mut issues = vec![];
98 let dep = input.backend.dependency();
99 match dependency_body(input.cargo_toml, dep) {
100 None => issues.push(format!("Cargo.toml must declare {dep}")),
101 Some(body) => {
102 if !body.replace(' ', "").contains("optional=true") {
103 issues.push(format!("{dep} must be optional"));
104 }
105 }
106 }
107 let feature = feature_list(input.cargo_toml, "qa-native");
108 if !feature.contains(&format!("dep:{dep}")) {
109 issues.push(format!("qa-native must enable dep:{dep}"));
110 }
111 let compact: String = input
112 .rust_source
113 .chars()
114 .filter(|c| !c.is_whitespace())
115 .collect();
116 if !compact.contains(r#"cfg(all(not(debug_assertions),feature="qa-native"))"#)
117 || !compact.contains("compile_error!")
118 {
119 issues
120 .push("Rust must compile_error when qa-native is enabled outside debug builds".into());
121 }
122 if !compact.contains(r#"cfg(all(debug_assertions,feature="qa-native"))"#)
123 || !compact.contains(input.backend.registration())
124 {
125 issues.push(format!(
126 "{dep} registration must require debug_assertions and qa-native"
127 ));
128 }
129 let gated = compact.contains(input.backend.runtime_flag())
130 || (input.backend == Backend::Control && compact.contains("build_if_enabled"));
131 if !gated {
132 issues.push(format!(
133 "registration must also require the {} runtime flag",
134 input.backend.runtime_flag()
135 ));
136 }
137 for (name, command) in input.package_scripts {
138 if !command.contains("qa-native") {
139 continue;
140 }
141 if ["qa", "test", "dev"]
142 .iter()
143 .any(|p| name == p || name.starts_with(&format!("{p}:")))
144 {
145 continue;
146 }
147 let lower = name.to_lowercase();
148 if ["release", "publish", "build", "dmg", "installer", "package"]
149 .iter()
150 .any(|w| lower.contains(w))
151 || command.contains("tauri build")
152 {
153 issues.push(format!("package script {name} must not enable qa-native"));
154 }
155 }
156 issues
157}
158
159pub fn assert_contract(input: &ContractInput) -> Result<()> {
160 let issues = validate(input);
161 if issues.is_empty() {
162 Ok(())
163 } else {
164 err(format!(
165 "native QA contract failed:\n- {}",
166 issues.join("\n- ")
167 ))
168 }
169}
170
171pub fn scripts_from_package_json(text: &str) -> Result<BTreeMap<String, String>> {
172 let v: Value = serde_json::from_str(text)?;
173 Ok(v.get("scripts")
174 .and_then(Value::as_object)
175 .map(|m| {
176 m.iter()
177 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
178 .collect()
179 })
180 .unwrap_or_default())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 const CARGO: &str = r#"
188[features]
189qa-native = ["dep:rightkit-control"]
190[dependencies]
191rightkit-control = { version = "0.1", optional = true, features = ["tauri-plugin"] }
192"#;
193 const RUST: &str = r#"
194#[cfg(all(not(debug_assertions), feature = "qa-native"))]
195compile_error!("qa-native is debug only");
196fn run() {
197 #[cfg(all(debug_assertions, feature = "qa-native"))]
198 if let Some(p) = rightkit_control::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
199}"#;
200
201 #[test]
202 fn accepts_debug_only_control_registration() {
203 let scripts = BTreeMap::from([(
204 "qa".to_string(),
205 "cargo run --features qa-native".to_string(),
206 )]);
207 let i = ContractInput {
208 cargo_toml: CARGO,
209 rust_source: RUST,
210 package_scripts: &scripts,
211 backend: Backend::Control,
212 };
213 assert_eq!(validate(&i), Vec::<String>::new());
214 }
215
216 #[test]
217 fn rejects_release_scripts_and_non_optional_dependency() {
218 let cargo = CARGO.replace("optional = true, ", "");
219 let scripts = BTreeMap::from([(
220 "release".to_string(),
221 "tauri build --features qa-native".to_string(),
222 )]);
223 let i = ContractInput {
224 cargo_toml: &cargo,
225 rust_source: RUST,
226 package_scripts: &scripts,
227 backend: Backend::Control,
228 };
229 let issues = validate(&i);
230 assert!(
231 issues.iter().any(|s| s.contains("must be optional")),
232 "{issues:?}"
233 );
234 assert!(
235 issues.iter().any(|s| s.contains("package script release")),
236 "{issues:?}"
237 );
238 }
239
240 #[test]
241 fn rejects_missing_compile_error() {
242 let scripts = BTreeMap::new();
243 let i = ContractInput {
244 cargo_toml: CARGO,
245 rust_source: "fn main(){}",
246 package_scripts: &scripts,
247 backend: Backend::Control,
248 };
249 assert!(validate(&i).iter().any(|s| s.contains("compile_error")));
250 }
251}