1use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9pub mod framework;
10mod typescript;
11
12const DOMAIN: &[u8] = b"tuffite.command.v1\0";
13
14pub mod shell_api;
16use shell_api::{Facade, GeneratedBindings, Output, OutputMode};
17
18#[derive(Debug)]
20pub struct BuildOutput {
21 pub framework: framework::Distribution,
22 pub shell_api: shell_api::GeneratedBindings,
23}
24
25#[derive(Debug, Default)]
28pub struct Builder {
29 steps: Vec<Step>,
30}
31
32#[derive(Debug)]
34pub enum Step {
35 Framework(framework::Resolver),
36 ShellApi(shell_api::Generator),
37}
38impl From<framework::Resolver> for Step {
39 fn from(value: framework::Resolver) -> Self {
40 Self::Framework(value)
41 }
42}
43impl From<shell_api::Generator> for Step {
44 fn from(value: shell_api::Generator) -> Self {
45 Self::ShellApi(value)
46 }
47}
48
49impl Builder {
50 #[must_use]
51 pub const fn new() -> Self {
52 Self { steps: Vec::new() }
53 }
54
55 #[must_use]
58 #[allow(clippy::should_implement_trait)]
60 pub fn add(mut self, step: impl Into<Step>) -> Self {
61 self.steps.push(step.into());
62 self
63 }
64
65 pub fn build(self) -> Result<BuildOutput, String> {
66 let mut resolver = None;
67 let mut generator = None;
68 for step in self.steps {
69 match step {
70 Step::Framework(value) => {
71 if resolver.replace(value).is_some() {
72 return Err("duplicate Framework Resolver".into());
73 }
74 }
75 Step::ShellApi(value) => {
76 if generator.replace(value).is_some() {
77 return Err("duplicate ShellAPI Generator".into());
78 }
79 }
80 }
81 }
82 let resolver = resolver.ok_or("tuffite::build::Builder requires a Framework Resolver")?;
83 let codegen = generator.ok_or("tuffite::build::Builder requires ShellAPI Generator")?;
84 let framework = resolver.resolve()?;
85 let generated = codegen.generate_for_build()?;
86 for name in framework::ENV_VARS {
87 println!("cargo:rerun-if-env-changed={name}");
88 }
89 for path in generated.rerun_paths {
90 println!("cargo:rerun-if-changed={}", path.display());
91 }
92 Ok(BuildOutput {
93 framework,
94 shell_api: generated.bindings,
95 })
96 }
97}
98
99fn emit_files(
100 directory: Option<&Output>,
101 files: &BTreeMap<String, String>,
102 description: &str,
103 plan_name: &str,
104 manifest: &Path,
105) -> Result<(), String> {
106 let Some(directory) = directory else {
107 return Ok(());
108 };
109 let plan_path = directory.path.join(plan_name);
110 let previous = fs::read(&plan_path)
111 .ok()
112 .and_then(|bytes| serde_json::from_slice::<Vec<String>>(&bytes).ok())
113 .unwrap_or_default();
114 for (name, contents) in files {
115 let output = Output {
116 path: directory.path.join(name),
117 mode: directory.mode,
118 };
119 emit(
120 Some(&output),
121 contents,
122 &format!("{description} {name}"),
123 manifest,
124 )?;
125 }
126 let planned = files.keys().cloned().collect::<Vec<_>>();
127 match directory.mode {
128 OutputMode::Write => {
129 for stale in previous.iter().filter(|name| !files.contains_key(*name)) {
130 let path = directory.path.join(stale);
131 if path.is_file() {
132 let contents = fs::read_to_string(&path).map_err(|error| {
133 format!("inspect stale generated file {}: {error}", path.display())
134 })?;
135 if !contents.starts_with("// Generated from ") {
136 return Err(format!(
137 "refusing to remove unmanaged stale output {}",
138 path.display()
139 ));
140 }
141 fs::remove_file(&path).map_err(|error| {
142 format!("remove stale generated file {}: {error}", path.display())
143 })?;
144 }
145 }
146 let contents = serde_json::to_string_pretty(&planned)
147 .map_err(|error| format!("serialize generation output plan: {error}"))?
148 + "\n";
149 write_if_changed(&plan_path, &contents)?;
150 }
151 OutputMode::Verify => {
152 if !previous.is_empty() && previous != planned {
153 return Err(format!(
154 "generated output plan {} is stale; run `tuff build {}`",
155 plan_path.display(),
156 manifest.display()
157 ));
158 }
159 }
160 }
161 Ok(())
162}
163
164fn emit(
165 output: Option<&Output>,
166 contents: &str,
167 description: &str,
168 manifest: &Path,
169) -> Result<(), String> {
170 let Some(output) = output else {
171 return Ok(());
172 };
173 match output.mode {
174 OutputMode::Write => {
175 write_if_changed(&output.path, contents)?;
176 Ok(())
177 }
178 OutputMode::Verify => {
179 let current = fs::read_to_string(&output.path).map_err(|error| {
180 format!(
181 "unable to verify {description} {}: {error}",
182 output.path.display()
183 )
184 })?;
185 if current == contents {
186 Ok(())
187 } else {
188 Err(format!(
189 "{description} {} is stale; run `tuff build {}`",
190 output.path.display(),
191 manifest.display()
192 ))
193 }
194 }
195 }
196}
197
198#[derive(Deserialize)]
199#[serde(rename_all = "camelCase")]
200struct Manifest {
201 app: AppConfig,
202 build: BuildConfig,
203}
204
205#[derive(Deserialize)]
206struct AppConfig {
207 identifier: String,
208}
209
210#[derive(Deserialize)]
211struct BuildConfig {
212 #[serde(rename = "shellApi")]
213 shell_api: ShellApi,
214}
215
216#[derive(Deserialize)]
217struct ShellApi {
218 namespace: String,
219 definitions: Vec<PathBuf>,
220 #[serde(default)]
221 facade: Facade,
222}
223
224fn command_id(name: &str) -> u64 {
225 let mut hasher = blake3::Hasher::new();
226 hasher.update(DOMAIN);
227 hasher.update(name.as_bytes());
228 let digest = hasher.finalize();
229 let mut bytes = [0_u8; 8];
230 bytes.copy_from_slice(&digest.as_bytes()[..8]);
231 u64::from_le_bytes(bytes)
232}
233
234fn read_shell_api_sources(
235 manifest_path: &Path,
236 definitions: &[PathBuf],
237) -> Result<Vec<(PathBuf, String)>, String> {
238 if definitions.is_empty() {
239 return Err("build.shellApi.definitions must not be empty".to_owned());
240 }
241 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
242 definitions
243 .iter()
244 .map(|declaration| {
245 if !declaration
246 .file_name()
247 .and_then(|value| value.to_str())
248 .is_some_and(|value| value.ends_with(".d.ts"))
249 {
250 return Err(format!(
251 "ShellAPI declaration must use the .d.ts extension: {}",
252 declaration.display()
253 ));
254 }
255 let path = root.join(declaration);
256 let source = fs::read_to_string(&path)
257 .map_err(|error| format!("unable to read {}: {error}", path.display()))?;
258 Ok((declaration.clone(), source))
259 })
260 .collect()
261}
262
263fn generate_bindings(
264 manifest_path: &Path,
265 facade_override: Option<Facade>,
266 cpp_include_root: &str,
267) -> Result<(GeneratedBindings, Vec<PathBuf>), String> {
268 let serialized = fs::read(manifest_path)
269 .map_err(|error| format!("unable to read {}: {error}", manifest_path.display()))?;
270 let manifest: Manifest = serde_json::from_slice(&serialized)
271 .map_err(|error| format!("invalid {}: {error}", manifest_path.display()))?;
272 let shell_api = &manifest.build.shell_api;
273 let facade = facade_override.unwrap_or(shell_api.facade);
274 if manifest.app.identifier.is_empty()
275 || !is_identifier(&shell_api.namespace)
276 || (shell_api.namespace == "tuffite" && facade != Facade::Framework)
277 {
278 return Err("app.identifier or shellApi.namespace is invalid".into());
279 }
280 let sources = read_shell_api_sources(manifest_path, &shell_api.definitions)?;
281 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
282 let inputs = sources
283 .iter()
284 .map(|(path, _)| root.join(path))
285 .collect::<Vec<_>>();
286 if cpp_include_root.is_empty()
287 || cpp_include_root.starts_with('/')
288 || cpp_include_root.contains("..")
289 || cpp_include_root.contains('\\')
290 {
291 return Err("C++ include root must be a safe source-relative path".to_owned());
292 }
293 let bindings = typescript::generate_with_cpp_include_root(
294 &sources,
295 &shell_api.namespace,
296 &manifest.app.identifier,
297 &shell_api.namespace,
298 facade,
299 cpp_include_root,
300 )?;
301 Ok((bindings, inputs))
302}
303
304#[cfg(feature = "cli")]
307pub(crate) fn security_command_ids(
308 manifest_path: &Path,
309) -> Result<BTreeMap<String, String>, String> {
310 let (bindings, _) = generate_bindings(manifest_path, None, "tuffite/framework/shell_api")?;
311 let mut ids = bindings.command_ids;
312 let sources = vec![
313 (
314 PathBuf::from("common.d.ts"),
315 include_str!("../../../framework/shell_api/common.d.ts").to_owned(),
316 ),
317 (
318 PathBuf::from("dialog.d.ts"),
319 include_str!("../../../framework/shell_api/dialog.d.ts").to_owned(),
320 ),
321 (
322 PathBuf::from("path.d.ts"),
323 include_str!("../../../framework/shell_api/path.d.ts").to_owned(),
324 ),
325 (
326 PathBuf::from("fs.d.ts"),
327 include_str!("../../../framework/shell_api/fs.d.ts").to_owned(),
328 ),
329 (
330 PathBuf::from("storage.d.ts"),
331 include_str!("../../../framework/shell_api/storage.d.ts").to_owned(),
332 ),
333 (
334 PathBuf::from("log.d.ts"),
335 include_str!("../../../framework/shell_api/log.d.ts").to_owned(),
336 ),
337 (
338 PathBuf::from("network.d.ts"),
339 include_str!("../../../framework/shell_api/network.d.ts").to_owned(),
340 ),
341 (
342 PathBuf::from("utility.d.ts"),
343 include_str!("../../../framework/shell_api/utility.d.ts").to_owned(),
344 ),
345 (
346 PathBuf::from("remoting.d.ts"),
347 include_str!("../../../framework/shell_api/remoting.d.ts").to_owned(),
348 ),
349 (
350 PathBuf::from("window.d.ts"),
351 include_str!("../../../framework/shell_api/window.d.ts").to_owned(),
352 ),
353 ];
354 for command in typescript::command_paths(&sources, "tuffite")? {
355 ids.entry(command)
356 .or_insert_with(|| "0000000000000000".to_owned());
357 }
358 let value: serde_json::Value =
359 serde_json::from_slice(&fs::read(manifest_path).map_err(|e| e.to_string())?)
360 .map_err(|e| e.to_string())?;
361 ids.insert(
362 "tuffite.devtools.inspectElement".into(),
363 "0000000000000000".into(),
364 );
365 if let Some(capabilities) = value
366 .pointer("/app/security/capabilities")
367 .and_then(serde_json::Value::as_array)
368 {
369 for capability in capabilities {
370 if let Some(entries) = capability
371 .get("shellApi")
372 .and_then(serde_json::Value::as_array)
373 {
374 for entry in entries {
375 validate_security_entry(entry, &ids)?;
376 }
377 }
378 }
379 }
380 Ok(ids)
381}
382
383#[cfg(feature = "cli")]
384pub(crate) fn method_matches(pattern: &str, method: &str) -> bool {
385 pattern == "*"
386 || pattern
387 .strip_suffix(".*")
388 .map_or(pattern == method, |prefix| {
389 method.starts_with(&format!("{prefix}."))
390 })
391}
392
393#[cfg(feature = "cli")]
394fn validate_security_entry(
395 entry: &serde_json::Value,
396 ids: &BTreeMap<String, String>,
397) -> Result<(), String> {
398 compile_security_entry(entry, ids).map(|_| ())
399}
400
401#[cfg(feature = "cli")]
402pub(crate) fn compile_security_entry(
403 entry: &serde_json::Value,
404 ids: &BTreeMap<String, String>,
405) -> Result<Vec<serde_json::Value>, String> {
406 let pattern = entry
407 .as_str()
408 .or_else(|| entry.get("method").and_then(serde_json::Value::as_str))
409 .ok_or("shellApi entry requires method")?;
410 let methods: Vec<_> = ids
411 .keys()
412 .filter(|name| method_matches(pattern, name))
413 .collect();
414 if methods.is_empty() {
415 return Err(format!(
416 "ShellAPI method pattern matches no registered method: {pattern}"
417 ));
418 }
419 let registry: serde_json::Value = serde_json::from_str(include_str!(
420 "../../../framework/shell_api/scope_registry.json"
421 ))
422 .map_err(|e| e.to_string())?;
423 if (entry.get("allow").is_some() || entry.get("deny").is_some())
424 && !methods.iter().any(|method| registry.get(*method).is_some())
425 {
426 return Err(format!("{pattern} does not support scopes"));
427 }
428 let mut compiled = Vec::new();
429 for method in methods {
430 let skip_scopes = pattern.ends_with(".*") && registry.get(method).is_none();
431 for field in ["allow", "deny"] {
432 if skip_scopes {
433 continue;
434 }
435 let Some(values) = entry.get(field) else {
436 continue;
437 };
438 let descriptor = registry
439 .get(method)
440 .ok_or_else(|| format!("{method} does not support scopes"))?;
441 for value in values.as_array().ok_or("scope must be an array")? {
442 validate_security_scope(method, descriptor, value)?;
443 }
444 }
445
446 let mut grant = entry.clone();
447 if grant.is_string() {
448 grant = serde_json::Value::String(method.clone());
449 } else {
450 grant["method"] = serde_json::Value::String(method.clone());
451 if skip_scopes {
452 let fields = grant.as_object_mut().expect("validated grant object");
453 fields.remove("allow");
454 fields.remove("deny");
455 }
456 }
457 compiled.push(grant);
458 }
459 Ok(compiled)
460}
461
462#[cfg(feature = "cli")]
463fn validate_security_scope(
464 method: &str,
465 descriptor: &serde_json::Value,
466 value: &serde_json::Value,
467) -> Result<(), String> {
468 let kind = descriptor["kind"].as_str().unwrap();
469 let object = value
470 .as_object()
471 .ok_or_else(|| format!("{method}: expected {kind} scope object"))?;
472 let key = match kind {
473 "path" => "path",
474 "url" => "url",
475 "window" => "window",
476 _ => unreachable!(),
477 };
478 if object.len() != 1 {
479 return Err(format!("{method}: scope must contain only {key}"));
480 }
481 let text = object
482 .get(key)
483 .and_then(serde_json::Value::as_str)
484 .ok_or_else(|| format!("{method}: scope requires string {key}"))?;
485 match kind {
486 "path" => {
487 let base = text.split('/').next().unwrap();
488 if descriptor["write"] == true && base == "resource" {
489 return Err(format!(
490 "{method}: resource is read-only; use data, cache or temp for write scopes (split read and write grants when using a wildcard method)"
491 ));
492 }
493 if !matches!(base, "resource" | "data" | "cache" | "temp" | "*")
494 || text.contains(['\\', ':', '?', '\0'])
495 || text.split('/').any(|p| p == ".." || p == ".")
496 {
497 return Err(format!("{method}: invalid path scope {text}"));
498 }
499 }
500 "url" => {
501 if text != "*" {
502 let candidate = text.replacen("*.", "sample.", 1).replace('*', "sample");
503 let url = url::Url::parse(&candidate)
504 .map_err(|_| format!("{method}: expected absolute HTTP(S) URL scope"))?;
505 if !matches!(url.scheme(), "http" | "https")
506 || url.host_str().is_none()
507 || !url.username().is_empty()
508 || url.password().is_some()
509 || url.query().is_some()
510 || url.fragment().is_some()
511 {
512 return Err(format!("{method}: invalid URL scope"));
513 }
514 }
515 }
516 "window" if !matches!(text, "current" | "others" | "*") => {
517 return Err(format!(
518 "{method}: window scope must be current, others or *"
519 ));
520 }
521 _ => {}
522 }
523 Ok(())
524}
525
526fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> {
527 if fs::read_to_string(path).ok().as_deref() == Some(contents) {
528 return Ok(false);
529 }
530 if let Some(parent) = path.parent() {
531 fs::create_dir_all(parent)
532 .map_err(|error| format!("unable to create {}: {error}", parent.display()))?;
533 }
534 fs::write(path, contents)
535 .map_err(|error| format!("unable to write {}: {error}", path.display()))?;
536 Ok(true)
537}
538
539fn is_identifier(value: &str) -> bool {
540 let mut characters = value.chars();
541 matches!(characters.next(), Some(first) if first.is_ascii_alphabetic() || first == '_' || first == '$')
542 && characters.all(|character| {
543 character.is_ascii_alphanumeric() || character == '_' || character == '$'
544 })
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[cfg(feature = "cli")]
552 #[test]
553 fn framework_registry_includes_remoting_methods() {
554 let manifest =
555 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages/tuffite/tuffite.json");
556 let ids = security_command_ids(&manifest).unwrap();
557 for method in [
558 "capabilities",
559 "start",
560 "processSignal",
561 "takeSignals",
562 "sendInput",
563 "stop",
564 "stats",
565 "capture",
566 ] {
567 assert!(ids.contains_key(&format!("tuffite.remoting.{method}")));
568 }
569 assert!(validate_security_entry(&serde_json::json!("tuffite.remoting.*"), &ids).is_ok());
570 }
571
572 #[cfg(feature = "cli")]
573 #[test]
574 fn namespace_group_scopes_compile_only_for_resource_methods() {
575 let ids = [
576 "tuffite.window.current",
577 "tuffite.window.focus",
578 "tuffite.path.directories",
579 ]
580 .into_iter()
581 .map(|method| (method.to_owned(), "0".to_owned()))
582 .collect();
583 let grants = compile_security_entry(
584 &serde_json::json!({
585 "method": "tuffite.window.*", "allow": [{"window": "*"}]
586 }),
587 &ids,
588 )
589 .unwrap();
590 let current = grants
591 .iter()
592 .find(|grant| grant["method"] == "tuffite.window.current")
593 .unwrap();
594 assert!(current.get("allow").is_none());
595 let focus = grants
596 .iter()
597 .find(|grant| grant["method"] == "tuffite.window.focus")
598 .unwrap();
599 assert_eq!(focus["allow"], serde_json::json!([{"window": "*"}]));
600 assert!(
601 compile_security_entry(
602 &serde_json::json!({
603 "method": "tuffite.window.*", "allow": [{"path": "data/**"}]
604 }),
605 &ids
606 )
607 .is_err()
608 );
609 assert!(
610 compile_security_entry(
611 &serde_json::json!({
612 "method": "tuffite.path.*", "allow": [{"path": "data/**"}]
613 }),
614 &ids
615 )
616 .is_err()
617 );
618 }
619
620 #[cfg(feature = "cli")]
621 #[test]
622 fn shell_api_patterns_and_scopes_are_module_validated() {
623 let ids: BTreeMap<String, String> = [
624 "tuffite.path.directories",
625 "tuffite.fs.readFile",
626 "tuffite.fs.writeFile",
627 "tuffite.window.focus",
628 ]
629 .into_iter()
630 .map(|name| (name.into(), "0".into()))
631 .collect();
632 assert!(validate_security_entry(&serde_json::json!("*"), &ids).is_ok());
633 assert!(!method_matches("tuffite.*.readFile", "tuffite.fs.readFile"));
634 assert!(
635 validate_security_entry(
636 &serde_json::json!({"method":"*","allow":[{"path":"data/**"}]}),
637 &ids
638 )
639 .is_err()
640 );
641 assert!(validate_security_entry(&serde_json::json!("tuffite.path.*"), &ids).is_ok());
642 assert!(validate_security_entry(&serde_json::json!("tuffite.unknown.*"), &ids).is_err());
643 assert!(validate_security_entry(&serde_json::json!({"method":"tuffite.fs.readFile","allow":[{"path":"resource/**"}]}), &ids).is_ok());
644 assert!(
645 validate_security_entry(
646 &serde_json::json!({"method":"tuffite.fs.*","allow":[{"path":"resource/**"}]}),
647 &ids
648 )
649 .is_err()
650 );
651 assert!(
652 validate_security_entry(
653 &serde_json::json!({"method":"tuffite.path.*","allow":[]}),
654 &ids
655 )
656 .is_err()
657 );
658 for value in [
659 serde_json::json!(true),
660 serde_json::json!(null),
661 serde_json::json!({"typo":"data/**"}),
662 ] {
663 assert!(
664 validate_security_entry(
665 &serde_json::json!({"method":"tuffite.fs.readFile","allow":[value]}),
666 &ids
667 )
668 .is_err()
669 );
670 }
671 assert!(
672 validate_security_entry(
673 &serde_json::json!({"method":"tuffite.window.focus","allow":[{"window":"*"}]}),
674 &ids
675 )
676 .is_ok()
677 );
678 }
679
680 #[test]
681 fn application_builder_requires_both_build_phases() {
682 let missing_framework = Builder::new().build().unwrap_err();
683 assert!(missing_framework.contains("Framework Resolver"));
684
685 let missing_shell_api = Builder::new()
686 .add(framework::Resolver::new())
687 .build()
688 .unwrap_err();
689 assert!(missing_shell_api.contains("ShellAPI Generator"));
690 }
691
692 #[test]
693 fn application_builder_rejects_duplicate_components_before_running_them() {
694 let error = Builder::new()
695 .add(shell_api::Generator::new())
696 .add(shell_api::Generator::new())
697 .build()
698 .unwrap_err();
699 assert!(error.contains("duplicate ShellAPI Generator"));
700 let error = Builder::new()
701 .add(framework::Resolver::new())
702 .add(framework::Resolver::new())
703 .build()
704 .unwrap_err();
705 assert!(error.contains("duplicate Framework Resolver"));
706 }
707
708 #[test]
709 fn command_id_is_stable() {
710 assert_eq!(
712 command_id("com.tuffite.demo:some_thing"),
713 0x1cfa_4aba_ac72_7a5b
714 );
715 }
716
717 #[test]
718 fn identifiers_reject_prototype_paths() {
719 assert!(is_identifier("some_thing"));
720 assert!(!is_identifier("__proto__.polluted"));
721 assert!(!is_identifier("has-hyphen"));
722 }
723
724 #[test]
725 fn reserved_framework_global_is_rejected() {
726 let root = std::env::temp_dir().join(format!(
727 "tuffite-builder-reserved-global-{}-{}",
728 std::process::id(),
729 command_id(module_path!())
730 ));
731 if root.exists() {
732 fs::remove_dir_all(&root).unwrap();
733 }
734 fs::create_dir_all(&root).unwrap();
735 let manifest = root.join("tuffite.json");
736 fs::write(
737 &manifest,
738 r#"{
739 "app": {
740 "identifier": "com.example.reserved"
741 },
742 "build": {
743 "shellApi": {
744 "namespace": "tuffite",
745 "definitions": [
746 "shell_api.d.ts"
747 ]
748 }
749 }
750}"#,
751 )
752 .unwrap();
753 fs::write(
754 root.join("shell_api.d.ts"),
755 "export declare namespace tuffite { export function ping(): Promise<string>; }",
756 )
757 .unwrap();
758 let error = shell_api::Generator::new()
759 .manifest(&manifest)
760 .generate()
761 .unwrap_err();
762 assert!(error.contains("shellApi.namespace is invalid"));
763 fs::remove_dir_all(root).unwrap();
764 }
765
766 #[test]
767 fn builder_writes_and_verifies_each_output() {
768 let root = std::env::temp_dir().join(format!(
769 "tuffite-builder-{}-{}",
770 std::process::id(),
771 command_id(module_path!())
772 ));
773 if root.exists() {
774 fs::remove_dir_all(&root).unwrap();
775 }
776 fs::create_dir_all(&root).unwrap();
777 let manifest = root.join("tuffite.json");
778 let definition = root.join("shell_api.d.ts");
779 fs::write(
780 &manifest,
781 r#"{
782 "app": {
783 "identifier": "com.example.builder"
784 },
785 "build": {
786 "shellApi": {
787 "namespace": "api",
788 "definitions": [
789 "shell_api.d.ts"
790 ]
791 }
792 }
793}"#,
794 )
795 .unwrap();
796 fs::write(
797 &definition,
798 "export declare namespace api { export function ping(): Promise<string>; }",
799 )
800 .unwrap();
801 let javascript = root.join("generated/index.js");
802 let generated = root.join("generated");
803 let write_generation = shell_api::Generator::new()
804 .manifest(&manifest)
805 .rust(&generated, OutputMode::Write)
806 .javascript(&generated, OutputMode::Write)
807 .generate_for_build()
808 .unwrap();
809 assert!(write_generation.rerun_paths.contains(&javascript));
810 assert!(
811 write_generation
812 .rerun_paths
813 .contains(&generated.join("shell_api.js"))
814 );
815 let build_generation = shell_api::Generator::new()
816 .manifest(&manifest)
817 .rust(&generated, OutputMode::Verify)
818 .javascript(&generated, OutputMode::Verify)
819 .generate_for_build()
820 .unwrap();
821 assert!(build_generation.rerun_paths.contains(&manifest));
822 assert!(build_generation.rerun_paths.contains(&definition));
823 assert!(build_generation.rerun_paths.contains(&javascript));
824 assert!(
825 build_generation
826 .rerun_paths
827 .contains(&generated.join("shell_api.js"))
828 );
829 assert!(
830 build_generation
831 .rerun_paths
832 .contains(&generated.join(".tuffite-javascript-outputs.json"))
833 );
834 assert!(generated.join("bindings.rs").is_file());
835 assert!(generated.join("shell_api.rs").is_file());
836 assert!(generated.join("shell_api.js").is_file());
837 assert!(generated.join("shell_api.d.ts").is_file());
838 let stale = generated.join("removed.js");
839 fs::write(&stale, "// Generated from removed.d.ts. Do not edit.\n").unwrap();
840 fs::write(
841 generated.join(".tuffite-javascript-outputs.json"),
842 "[\"removed.js\", \"shell_api.js\"]\n",
843 )
844 .unwrap();
845 shell_api::Generator::new()
846 .manifest(&manifest)
847 .javascript(&generated, OutputMode::Write)
848 .generate()
849 .unwrap();
850 assert!(!stale.exists());
851 fs::write(&javascript, "stale").unwrap();
852 let error = shell_api::Generator::new()
853 .manifest(&manifest)
854 .javascript(&generated, OutputMode::Verify)
855 .generate()
856 .unwrap_err();
857 assert!(error.contains("is stale"));
858 fs::remove_dir_all(root).unwrap();
859 }
860
861 #[test]
862 fn builder_merges_multiple_declaration_files() {
863 let root = std::env::temp_dir().join(format!(
864 "tuffite-builder-multiple-{}-{}",
865 std::process::id(),
866 command_id(module_path!())
867 ));
868 if root.exists() {
869 fs::remove_dir_all(&root).unwrap();
870 }
871 fs::create_dir_all(&root).unwrap();
872 let manifest = root.join("tuffite.json");
873 fs::write(
874 &manifest,
875 r#"{
876 "app": {
877 "identifier": "com.example.multiple"
878 },
879 "build": {
880 "shellApi": {
881 "namespace": "api",
882 "definitions": [
883 "alpha.d.ts",
884 "beta.d.ts"
885 ]
886 }
887 }
888}"#,
889 )
890 .unwrap();
891 fs::write(
892 root.join("alpha.d.ts"),
893 "export declare namespace api { export namespace alpha { export function ping(): Promise<string>; } }",
894 )
895 .unwrap();
896 fs::write(
897 root.join("beta.d.ts"),
898 "export declare namespace api { export namespace beta { export function pong(): Promise<number>; } }",
899 )
900 .unwrap();
901 let generated = shell_api::Generator::new()
902 .manifest(&manifest)
903 .generate()
904 .unwrap();
905 assert!(generated.rust.contains("pub mod alpha"));
906 assert!(generated.rust.contains("pub mod beta"));
907 assert!(generated.javascript.contains("alpha.ping"));
908 assert!(generated.javascript.contains("beta.pong"));
909 assert!(generated.declarations.contains(" & "));
910 fs::remove_dir_all(root).unwrap();
911 }
912}