Skip to main content

rightkit_qa/
guard.rs

1//! The guard that keeps the in-app QA control surface out of production builds.
2//!
3//! Two backends are understood: `control` (the in-app `rightkit-control` server,
4//! the default) and `wdio` (the legacy `tauri-plugin-wdio-webdriver`, kept so an
5//! app mid-migration still validates).
6use 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
51/// Text of `name = { ... }` (inline table) or the `[dependencies.name]` table.
52fn 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}