1use crate::util::{err, Result};
12use serde_json::Value;
13use std::collections::{BTreeMap, BTreeSet};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Backend {
17 Control,
18 Wdio,
19}
20
21impl Backend {
22 pub fn parse(s: &str) -> Option<Self> {
23 match s {
24 "control" => Some(Backend::Control),
25 "wdio" => Some(Backend::Wdio),
26 _ => None,
27 }
28 }
29 fn dependency(self) -> &'static str {
30 match self {
31 Backend::Control => "rightkit-qa-driver",
32 Backend::Wdio => "tauri-plugin-wdio-webdriver",
33 }
34 }
35 fn registration(self) -> &'static str {
36 match self {
37 Backend::Control => "rightkit_qa_driver::embedded::Control",
38 Backend::Wdio => "tauri_plugin_wdio_webdriver::init",
39 }
40 }
41 fn runtime_flag(self) -> &'static str {
42 match self {
43 Backend::Control => "RIGHTKIT_CONTROL_SERVICE",
44 Backend::Wdio => "RIGHTKIT_QA_NATIVE",
45 }
46 }
47}
48
49pub struct ContractInput<'a> {
50 pub cargo_toml: &'a str,
51 pub rust_source: &'a str,
52 pub package_scripts: &'a BTreeMap<String, String>,
53 pub backend: Backend,
54}
55
56const QA_NATIVE_GATES: [&str; 3] = [
59 r#"#[cfg(feature="qa-native")]"#,
60 r#"#[cfg(all(debug_assertions,feature="qa-native"))]"#,
61 r#"#[cfg(all(feature="qa-native",debug_assertions))]"#,
62];
63
64const DEV_INSPECT_FEATURES: [&str; 6] = [
67 "dev-inspect",
68 "rightkit-tauri-shell/dev-inspect",
69 "rightkit-tauri-shell?/dev-inspect",
70 "rightkit-shell/dev-inspect",
72 "rightkit-shell?/dev-inspect",
73 "dev-native",
74];
75
76fn compact(text: &str) -> String {
77 text.chars().filter(|c| !c.is_whitespace()).collect()
78}
79
80fn dependency_body(cargo: &str, name: &str) -> Option<String> {
82 let mut search = 0;
83 while let Some(i) = cargo[search..].find(name) {
84 let at = search + i;
85 search = at + name.len();
86 let prev = if at == 0 {
87 b' '
88 } else {
89 cargo.as_bytes()[at - 1]
90 };
91 let before_ok = !prev.is_ascii_alphanumeric() && prev != b'-' && prev != b'_';
92 let rest = cargo[at + name.len()..].trim_start();
93 if before_ok && rest.starts_with('=') {
94 let rest = rest[1..].trim_start();
95 if let Some(body) = rest.strip_prefix('{') {
96 return body.find('}').map(|e| body[..e].to_string());
97 }
98 return Some(String::new());
99 }
100 }
101 cargo.find(&format!("[dependencies.{name}]")).map(|i| {
102 let tail = &cargo[i..];
103 let end = tail[1..].find("\n[").map(|e| e + 1).unwrap_or(tail.len());
104 tail[..end].to_string()
105 })
106}
107
108fn features_table(cargo: &str) -> &str {
110 let Some(start) = cargo.find("[features]") else {
111 return "";
112 };
113 let body = &cargo[start + "[features]".len()..];
114 let end = body.find("\n[").unwrap_or(body.len());
115 &body[..end]
116}
117
118fn feature_entries(cargo: &str) -> Vec<(String, String)> {
121 let table = features_table(cargo);
122 let mut out = Vec::new();
123 let mut pos = 0;
124 while pos < table.len() {
125 let line_end = table[pos..].find('\n').map_or(table.len(), |i| pos + i + 1);
126 let trimmed = table[pos..line_end].trim();
127 if let Some((key, value)) = trimmed.split_once('=') {
128 let key = key.trim();
129 if let Some(list) = value.trim_start().strip_prefix('[') {
130 if !key.is_empty() && !key.starts_with('#') {
131 let mut body = list.to_string();
132 let mut end = line_end;
133 while !body.contains(']') && end < table.len() {
134 let next = table[end..].find('\n').map_or(table.len(), |i| end + i + 1);
135 body.push_str(&table[end..next]);
136 end = next;
137 }
138 let close = body.find(']').unwrap_or(body.len());
139 out.push((key.to_string(), body[..close].to_string()));
140 pos = end;
141 continue;
142 }
143 }
144 }
145 pos = line_end;
146 }
147 out
148}
149
150fn list_items(list: &str) -> Vec<String> {
152 list.split(',')
153 .map(|s| s.trim().trim_matches('"').trim().to_string())
154 .filter(|s| !s.is_empty())
155 .collect()
156}
157
158fn default_reaches(entries: &[(String, String)], target: &str) -> bool {
160 let mut stack: Vec<String> = entries
161 .iter()
162 .filter(|(k, _)| k.as_str() == "default")
163 .flat_map(|(_, v)| list_items(v))
164 .collect();
165 let mut seen = BTreeSet::new();
166 while let Some(item) = stack.pop() {
167 if item == target {
168 return true;
169 }
170 if !seen.insert(item.clone()) {
171 continue;
172 }
173 if let Some((_, list)) = entries.iter().find(|(k, _)| k.as_str() == item.as_str()) {
174 stack.extend(list_items(list));
175 }
176 }
177 false
178}
179
180fn is_code_at(src: &str, at: usize) -> bool {
183 let line_start = src[..at].rfind('\n').map_or(0, |p| p + 1);
184 let line = src[line_start..].lines().next().unwrap_or("").trim_start();
185 !(line.starts_with("use ")
186 || line.starts_with("pub use ")
187 || line.starts_with("pub(crate) use ")
188 || line.starts_with("//"))
189}
190
191fn is_gated(src: &str, at: usize) -> bool {
195 let mut end = at;
196 for _ in 0..8 {
197 let found = src[..end].rfind([';', '{', '}']);
198 let head_start = found.map_or(0, |p| p + 1);
199 let head = compact(&src[head_start..end]);
200 if QA_NATIVE_GATES.iter().any(|gate| head.contains(*gate)) {
201 return true;
202 }
203 match found {
204 Some(p) if src.as_bytes()[p] == b'{' => end = p,
205 _ => return false,
206 }
207 }
208 false
209}
210
211pub fn validate(input: &ContractInput) -> Vec<String> {
212 let mut issues = vec![];
213 let dep = input.backend.dependency();
214 let control = input.backend == Backend::Control;
215 let dep_body = dependency_body(input.cargo_toml, dep).map(|b| compact(&b));
216 let dep_enables_qa_control = dep_body
217 .as_deref()
218 .is_some_and(|b| b.contains("\"qa-control\""));
219 match &dep_body {
220 None => issues.push(format!(
221 "Cargo.toml must declare {dep} under [dependencies] as an optional dependency: \
222 `{dep} = {{ version = \"...\", optional = true, default-features = false }}`"
223 )),
224 Some(body) => {
225 if !body.contains("optional=true") {
226 issues.push(format!(
227 "{dep} must be optional: declare it with `optional = true` so that only \
228 the qa-native feature compiles the QA server into the app"
229 ));
230 if control && dep_enables_qa_control {
231 issues.push(format!(
232 "{dep} enables the qa-control feature without optional = true, so the \
233 QA server compiles into every build: make the dependency optional and \
234 enable qa-control only from qa-native"
235 ));
236 }
237 }
238 }
239 }
240
241 let entries = feature_entries(input.cargo_toml);
242 let dep_feature = format!("dep:{dep}");
243 let qa_control_feature = format!("{dep}/qa-control");
244 match entries.iter().find(|(k, _)| k.as_str() == "qa-native") {
245 None => issues.push(
246 "Cargo.toml must define the qa-native feature under [features], for example \
247 `qa-native = [\"dep:rightkit-qa-driver\", \"rightkit-qa-driver/qa-control\"]`"
248 .into(),
249 ),
250 Some((_, list)) => {
251 let items = list_items(list);
252 if !items.iter().any(|i| i.as_str() == dep_feature.as_str()) {
253 issues.push(format!("qa-native must enable {dep_feature}"));
254 }
255 let enables_control_qa = items
256 .iter()
257 .any(|i| i.as_str() == qa_control_feature.as_str());
258 if control && !enables_control_qa && !dep_enables_qa_control {
259 issues.push(
260 "qa-native must enable rightkit-qa-driver/qa-control (add it to the qa-native \
261 list, or list qa-control in the rightkit-qa-driver dependency's features); \
262 without it the embedded server is not compiled"
263 .into(),
264 );
265 }
266 }
267 }
268 if default_reaches(&entries, "qa-native") {
269 issues.push(
270 "qa-native must not be enabled by default: remove it from `default` (directly or \
271 through another default feature) so that only `--features qa-native` builds \
272 contain the QA server"
273 .into(),
274 );
275 }
276 for target in DEV_INSPECT_FEATURES {
277 if default_reaches(&entries, target) {
278 issues.push(format!(
279 "{target} must not be enabled by default: it compiles developer inspection \
280 (rightkit-tauri-shell dev-inspect) into the app, and right-release refuses to seal \
281 or upload such a build. Remove it from `default` (directly or through another \
282 default feature) so that only an explicit dev build contains it"
283 ));
284 }
285 }
286 for shell_crate in ["rightkit-tauri-shell", "rightkit-shell"] {
288 if let Some(body) = dependency_body(input.cargo_toml, shell_crate).map(|b| compact(&b)) {
289 if body.contains("\"dev-inspect\"") && !body.contains("optional=true") {
290 issues.push(format!(
291 "{shell_crate} lists the dev-inspect feature without optional = true, so \
292 developer inspection compiles into every build: enable it only from an \
293 optional dev feature such as `dev-native = [\"{shell_crate}/dev-inspect\"]`"
294 ));
295 }
296 }
297 }
298 for (name, list) in &entries {
299 if name.as_str() == "qa-native" {
300 continue;
301 }
302 let items = list_items(list);
303 if items.iter().any(|i| {
304 i.as_str() == dep_feature.as_str() || i.as_str() == qa_control_feature.as_str()
305 }) {
306 issues.push(format!(
307 "feature {name} enables {dep}; only qa-native may enable the QA server \
308 dependency, so remove it from {name}"
309 ));
310 }
311 }
312
313 let registration = input.backend.registration();
314 let sites: Vec<usize> = input
315 .rust_source
316 .match_indices(registration)
317 .map(|(at, _)| at)
318 .filter(|&at| is_code_at(input.rust_source, at))
319 .collect();
320 if sites.is_empty() {
321 issues.push(format!(
322 "the app must register {dep} in its Rust source: `{registration}` inside \
323 `#[cfg(feature = \"qa-native\")]`"
324 ));
325 }
326 let ungated: Vec<usize> = sites
327 .iter()
328 .copied()
329 .filter(|&at| !is_gated(input.rust_source, at))
330 .collect();
331 if let Some(&first) = ungated.first() {
332 let line = input.rust_source[..first].matches('\n').count() + 1;
333 issues.push(format!(
334 "{dep} registration `{registration}` is unconditional ({} place(s), first at line \
335 {line} of the Rust sources): gate each registration statement, or the function or \
336 block that contains it, with `#[cfg(feature = \"qa-native\")]` so that a build \
337 without the qa-native feature does not contain the QA server",
338 ungated.len()
339 ));
340 }
341
342 let rust_compact = compact(input.rust_source);
343 let runtime_gated = rust_compact.contains(input.backend.runtime_flag())
344 || (control && rust_compact.contains("build_if_enabled"));
345 if !runtime_gated {
346 issues.push(format!(
347 "registration must also require the {} runtime flag",
348 input.backend.runtime_flag()
349 ));
350 }
351
352 for (name, command) in input.package_scripts {
353 if command.contains("dev-inspect") || command.contains("dev-native") {
354 let lower = name.to_lowercase();
355 let dev_lane = ["qa", "test", "dev"]
356 .iter()
357 .any(|p| name == p || name.starts_with(&format!("{p}:")));
358 if !dev_lane
359 && ["release", "publish", "dmg", "installer", "package"]
360 .iter()
361 .any(|w| lower.contains(w))
362 {
363 issues.push(format!(
364 "package script {name} must not enable dev-inspect or dev-native; only \
365 qa, test or dev scripts may"
366 ));
367 }
368 }
369 if !command.contains("qa-native") {
370 continue;
371 }
372 if ["qa", "test", "dev"]
373 .iter()
374 .any(|p| name == p || name.starts_with(&format!("{p}:")))
375 {
376 continue;
377 }
378 let lower = name.to_lowercase();
379 if ["release", "publish", "build", "dmg", "installer", "package"]
380 .iter()
381 .any(|w| lower.contains(w))
382 || command.contains("tauri build")
383 {
384 issues.push(format!(
385 "package script {name} must not enable qa-native; only qa, test or dev scripts may"
386 ));
387 }
388 }
389 issues
390}
391
392pub fn assert_contract(input: &ContractInput) -> Result<()> {
393 let issues = validate(input);
394 if issues.is_empty() {
395 Ok(())
396 } else {
397 err(format!(
398 "native QA contract failed:\n- {}",
399 issues.join("\n- ")
400 ))
401 }
402}
403
404pub fn scripts_from_package_json(text: &str) -> Result<BTreeMap<String, String>> {
405 let v: Value = serde_json::from_str(text)?;
406 Ok(v.get("scripts")
407 .and_then(Value::as_object)
408 .map(|m| {
409 m.iter()
410 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
411 .collect()
412 })
413 .unwrap_or_default())
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 const CARGO: &str = r##"
422[features]
423default = []
424qa-native = ["dep:rightkit-qa-driver", "rightkit-qa-driver/qa-control"]
425[dependencies]
426rightkit-qa-driver = { version = "0.3", optional = true, default-features = false }
427"##;
428
429 const CARGO_DEP_FEATURES: &str = r##"
431[features]
432default = []
433qa-native = ["dep:rightkit-qa-driver"]
434[dependencies]
435rightkit-qa-driver = { version = "0.3", optional = true, default-features = false, features = ["qa-control"] }
436"##;
437
438 const RUST_GATED: &str = r##"
440use rightkit_qa_driver::embedded::Control;
441fn run() {
442 #[cfg(feature = "qa-native")]
443 if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
444}"##;
445
446 const RUST_DEBUG_ONLY: &str = r##"
448#[cfg(all(not(debug_assertions), feature = "qa-native"))]
449compile_error!("qa-native is debug only");
450fn run() {
451 #[cfg(all(debug_assertions, feature = "qa-native"))]
452 if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
453}"##;
454
455 const RUST_FN_GATED: &str = r##"
457#[cfg(feature = "qa-native")]
458fn register(b: Builder) -> Builder {
459 if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b.plugin(p) } else { b }
460}"##;
461
462 const RUST_UNGATED: &str = r##"
463use rightkit_qa_driver::embedded::Control;
464fn run() {
465 if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
466}"##;
467
468 fn check(cargo: &str, rust: &str, scripts: &[(&str, &str)], backend: Backend) -> Vec<String> {
469 let scripts: BTreeMap<String, String> = scripts
470 .iter()
471 .map(|(k, v)| (k.to_string(), v.to_string()))
472 .collect();
473 validate(&ContractInput {
474 cargo_toml: cargo,
475 rust_source: rust,
476 package_scripts: &scripts,
477 backend,
478 })
479 }
480
481 fn control(cargo: &str, rust: &str, scripts: &[(&str, &str)]) -> Vec<String> {
482 check(cargo, rust, scripts, Backend::Control)
483 }
484
485 fn any_contains(issues: &[String], needle: &str) -> bool {
486 issues.iter().any(|s| s.contains(needle))
487 }
488
489 #[test]
490 fn accepts_qa_native_gate_without_debug_assertions() {
491 let issues = control(
492 CARGO,
493 RUST_GATED,
494 &[("qa", "cargo run --features qa-native")],
495 );
496 assert_eq!(issues, Vec::<String>::new());
497 }
498
499 #[test]
500 fn accepts_older_debug_only_form() {
501 let issues = control(
502 CARGO,
503 RUST_DEBUG_ONLY,
504 &[("qa", "cargo run --features qa-native")],
505 );
506 assert_eq!(issues, Vec::<String>::new());
507 }
508
509 #[test]
510 fn accepts_gate_on_enclosing_function() {
511 assert_eq!(control(CARGO, RUST_FN_GATED, &[]), Vec::<String>::new());
512 }
513
514 #[test]
515 fn accepts_qa_control_listed_in_dependency_features() {
516 assert_eq!(
517 control(CARGO_DEP_FEATURES, RUST_GATED, &[]),
518 Vec::<String>::new()
519 );
520 }
521
522 #[test]
523 fn accepts_wdio_backend_gated_by_qa_native() {
524 let cargo = r##"
525[features]
526default = []
527qa-native = ["dep:tauri-plugin-wdio-webdriver"]
528[dependencies]
529tauri-plugin-wdio-webdriver = { version = "0.1", optional = true }
530"##;
531 let rust = r##"
532const FLAG: &str = "RIGHTKIT_QA_NATIVE";
533#[cfg(feature = "qa-native")]
534fn run() { b = b.plugin(tauri_plugin_wdio_webdriver::init()); }
535"##;
536 let issues = check(cargo, rust, &[], Backend::Wdio);
537 assert_eq!(issues, Vec::<String>::new());
538 }
539
540 const CARGO_DEV_INSPECT: &str = r##"
542[features]
543default = []
544qa-native = ["dep:rightkit-qa-driver", "rightkit-qa-driver/qa-control"]
545dev-native = ["rightkit-tauri-shell/dev-inspect"]
546[dependencies]
547rightkit-qa-driver = { version = "0.3", optional = true, default-features = false }
548rightkit-tauri-shell = { version = "0.4", default-features = false }
549"##;
550
551 #[test]
552 fn accepts_dev_inspect_behind_an_off_by_default_dev_feature() {
553 assert_eq!(
554 control(
555 CARGO_DEV_INSPECT,
556 RUST_GATED,
557 &[("dev", "cargo run --features dev-native")]
558 ),
559 Vec::<String>::new()
560 );
561 }
562
563 #[test]
564 fn rejects_dev_inspect_in_default_features() {
565 for default in [
566 "default = [\"dev-native\"]",
567 "default = [\"rightkit-tauri-shell/dev-inspect\"]",
568 "default = [\"dev-inspect\"]",
569 "default = [\"shell\"]\nshell = [\"dev-native\"]",
570 ] {
571 let cargo = CARGO_DEV_INSPECT.replace("default = []", default);
572 let issues = control(&cargo, RUST_GATED, &[]);
573 assert!(
574 any_contains(&issues, "must not be enabled by default"),
575 "{default}: {issues:?}"
576 );
577 }
578 }
579
580 #[test]
581 fn rejects_non_optional_dev_inspect_dependency_and_release_scripts() {
582 let cargo = CARGO_DEV_INSPECT.replace(
583 "rightkit-tauri-shell = { version = \"0.4\", default-features = false }",
584 "rightkit-tauri-shell = { version = \"0.4\", features = [\"dev-inspect\"] }",
585 );
586 let issues = control(&cargo, RUST_GATED, &[]);
587 assert!(
588 any_contains(&issues, "without optional = true"),
589 "{issues:?}"
590 );
591 let issues = control(
592 CARGO_DEV_INSPECT,
593 RUST_GATED,
594 &[("build:release", "tauri build --features dev-native")],
595 );
596 assert!(
597 any_contains(
598 &issues,
599 "package script build:release must not enable dev-inspect"
600 ),
601 "{issues:?}"
602 );
603 }
604
605 #[test]
606 fn rejects_unconditional_registration() {
607 let issues = control(CARGO, RUST_UNGATED, &[]);
608 assert!(any_contains(&issues, "is unconditional"), "{issues:?}");
609 }
610
611 #[test]
612 fn rejects_negated_gate() {
613 let negated = RUST_GATED.replace(
614 "#[cfg(feature = \"qa-native\")]",
615 "#[cfg(not(feature = \"qa-native\"))]",
616 );
617 let issues = control(CARGO, &negated, &[]);
618 assert!(any_contains(&issues, "is unconditional"), "{issues:?}");
619 }
620
621 #[test]
622 fn rejects_release_scripts_and_non_optional_dependency() {
623 let cargo = CARGO.replace("optional = true, ", "");
624 let issues = control(
625 &cargo,
626 RUST_GATED,
627 &[("release", "tauri build --features qa-native")],
628 );
629 assert!(any_contains(&issues, "must be optional"), "{issues:?}");
630 assert!(
631 any_contains(&issues, "package script release"),
632 "{issues:?}"
633 );
634 }
635
636 #[test]
637 fn rejects_non_optional_dependency_that_enables_qa_control() {
638 let cargo = CARGO_DEP_FEATURES.replace("optional = true, ", "");
639 let issues = control(&cargo, RUST_GATED, &[]);
640 assert!(any_contains(&issues, "must be optional"), "{issues:?}");
641 assert!(
642 any_contains(
643 &issues,
644 "enables the qa-control feature without optional = true"
645 ),
646 "{issues:?}"
647 );
648 }
649
650 #[test]
651 fn rejects_qa_native_in_default_features() {
652 let cargo = CARGO.replace("default = []", "default = [\"qa-native\"]");
653 let issues = control(&cargo, RUST_GATED, &[]);
654 assert!(
655 any_contains(&issues, "must not be enabled by default"),
656 "{issues:?}"
657 );
658 }
659
660 #[test]
661 fn rejects_qa_native_reached_through_a_default_feature() {
662 let cargo = CARGO.replace(
663 "default = []",
664 "default = [\"full\"]\nfull = [\"qa-native\"]",
665 );
666 let issues = control(&cargo, RUST_GATED, &[]);
667 assert!(
668 any_contains(&issues, "must not be enabled by default"),
669 "{issues:?}"
670 );
671 }
672
673 #[test]
674 fn rejects_qa_native_without_qa_control() {
675 let cargo = CARGO.replace(", \"rightkit-qa-driver/qa-control\"", "");
676 let issues = control(&cargo, RUST_GATED, &[]);
677 assert!(
678 any_contains(&issues, "rightkit-qa-driver/qa-control"),
679 "{issues:?}"
680 );
681 }
682
683 #[test]
684 fn rejects_other_feature_that_enables_the_dependency() {
685 let cargo = CARGO.replace(
686 "[dependencies]",
687 "browser-ui = [\"dep:rightkit-qa-driver\"]\n[dependencies]",
688 );
689 let issues = control(&cargo, RUST_GATED, &[]);
690 assert!(
691 any_contains(&issues, "feature browser-ui enables"),
692 "{issues:?}"
693 );
694 }
695
696 #[test]
697 fn rejects_missing_qa_native_feature() {
698 let cargo = CARGO.replace(
699 "qa-native = [\"dep:rightkit-qa-driver\", \"rightkit-qa-driver/qa-control\"]\n",
700 "",
701 );
702 let issues = control(&cargo, RUST_GATED, &[]);
703 assert!(
704 any_contains(&issues, "must define the qa-native feature"),
705 "{issues:?}"
706 );
707 }
708
709 #[test]
710 fn rejects_missing_registration() {
711 let issues = control(CARGO, "fn main(){}", &[]);
712 assert!(any_contains(&issues, "must register"), "{issues:?}");
713 }
714}