tuffite/
build.rs

1//! Build-time validation and source generation for Tuffite applications.
2
3use 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
14/// ShellAPI source generation and output verification.
15pub mod shell_api;
16use shell_api::{Facade, GeneratedBindings, Output, OutputMode};
17
18/// Result of preparing both the native Framework and generated `ShellAPI` code.
19#[derive(Debug)]
20pub struct BuildOutput {
21    pub framework: framework::Distribution,
22    pub shell_api: shell_api::GeneratedBindings,
23}
24
25/// Composes Framework resolution and `ShellAPI` code generation for application
26/// build scripts.
27#[derive(Debug, Default)]
28pub struct Builder {
29    steps: Vec<Step>,
30}
31
32/// Supported application build components, accepted by [`Builder::add`].
33#[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    /// Adds a Framework resolver or `ShellAPI` generator. Each may occur once;
56    /// Framework resolution always precedes source generation.
57    #[must_use]
58    // Build composition follows Builder.add(component), rather than arithmetic.
59    #[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/// Package-owned IDL registry. Framework method IDs are unused by its raw
305/// transport, but including their names lets native validation fail closed.
306#[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        // Golden value derived from DOMAIN = b"tuffite.command.v1\0".
711        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}