1use anyhow::{anyhow, Context, Result};
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Path, PathBuf};
33use whisker_config::Config;
34use whisker_plugin::{FileEntry, MetaDataEntry};
35
36use crate::compose::{EnabledTargets, Engine};
37use crate::fingerprint;
38use crate::render::{escape_xml, render};
39
40const APP_BUILD_GRADLE_KTS: &str = include_str!("templates/android/app/build.gradle.kts");
47const APP_MANIFEST_XML: &str = include_str!("templates/android/app/src/main/AndroidManifest.xml");
48const MAIN_ACTIVITY_KT: &str =
49 include_str!("templates/android/app/src/main/kotlin/MainActivity.kt");
50const APPLICATION_KT: &str = include_str!("templates/android/app/src/main/kotlin/Application.kt");
51const ROOT_BUILD_GRADLE_KTS: &str = include_str!("templates/android/build.gradle.kts");
52const SETTINGS_GRADLE_KTS: &str = include_str!("templates/android/settings.gradle.kts");
53const GRADLE_PROPERTIES: &str = include_str!("templates/android/gradle.properties");
54const GRADLEW: &str = include_str!("templates/android/gradlew");
55const GRADLEW_BAT: &str = include_str!("templates/android/gradlew.bat");
56const GRADLE_WRAPPER_PROPERTIES: &str =
57 include_str!("templates/android/gradle/wrapper/gradle-wrapper.properties");
58const GRADLE_WRAPPER_JAR: &[u8] =
59 include_bytes!("templates/android/gradle/wrapper/gradle-wrapper.jar");
60
61#[derive(Debug, Clone, serde::Serialize)]
69pub struct AndroidInputs {
70 pub app_name: String,
71 pub version: String,
72 pub build_number: u32,
73 pub application_id: String,
74 pub min_sdk: u32,
75 pub target_sdk: u32,
76 pub rust_lib_name: String,
79 pub whisker_workspace_path: PathBuf,
85 pub whisker_user_package: String,
90 pub whisker_sdk_version: String,
93 pub whisker_gradle_plugin_version: String,
98 pub whisker_maven_url: String,
102 pub lynx_maven_url: String,
105 #[serde(default)]
110 pub extra_permissions: Vec<String>,
111 #[serde(default)]
116 pub extra_meta_data: Vec<MetaDataEntry>,
117 #[serde(default)]
124 pub extra_gradle_plugins: Vec<String>,
125 #[serde(default)]
130 pub extra_gradle_dependencies: Vec<String>,
131 #[serde(default)]
145 pub extra_files: BTreeMap<PathBuf, FileEntry>,
146 pub template_version: u32,
150}
151
152pub fn sync(out_dir: &Path, inputs: &AndroidInputs) -> Result<bool> {
158 let new_fp = fingerprint::fingerprint(
159 serde_json::to_vec(inputs)
160 .context("serialize AndroidInputs for fingerprint")?
161 .as_slice(),
162 );
163 let fp_path = out_dir.join(".whisker-fingerprint");
164 if let Ok(existing) = std::fs::read_to_string(&fp_path) {
165 if existing.trim() == new_fp {
166 return Ok(false);
167 }
168 }
169
170 write_files(out_dir, inputs).context("write Android project files")?;
171 std::fs::write(&fp_path, &new_fp)
172 .with_context(|| format!("write fingerprint {}", fp_path.display()))?;
173 Ok(true)
174}
175
176pub(crate) fn template_vars(inputs: &AndroidInputs) -> HashMap<&'static str, String> {
179 let mut v = HashMap::new();
180 v.insert("app_name", inputs.app_name.clone());
181 v.insert("version", inputs.version.clone());
182 v.insert("build_number", inputs.build_number.to_string());
183 v.insert("android_application_id", inputs.application_id.clone());
184 v.insert(
185 "android_application_class",
186 application_class_name(&inputs.app_name),
187 );
188 v.insert("android_min_sdk", inputs.min_sdk.to_string());
189 v.insert("android_target_sdk", inputs.target_sdk.to_string());
190 v.insert("android_project_name", project_name(&inputs.app_name));
191 v.insert("rust_lib_name", inputs.rust_lib_name.clone());
192 v.insert(
193 "whisker_workspace_path",
194 inputs.whisker_workspace_path.display().to_string(),
195 );
196 v.insert("whisker_user_package", inputs.whisker_user_package.clone());
197 v.insert("whisker_sdk_version", inputs.whisker_sdk_version.clone());
198 v.insert(
199 "whisker_gradle_plugin_version",
200 inputs.whisker_gradle_plugin_version.clone(),
201 );
202 v.insert("whisker_maven_url", inputs.whisker_maven_url.clone());
203 v.insert("lynx_maven_url", inputs.lynx_maven_url.clone());
204 v.insert(
205 "extra_uses_permissions",
206 render_extra_permissions(&inputs.extra_permissions),
207 );
208 v.insert(
209 "extra_application_meta_data",
210 render_extra_meta_data(&inputs.extra_meta_data),
211 );
212 v.insert(
213 "extra_gradle_plugins",
214 render_extra_gradle_plugins(&inputs.extra_gradle_plugins),
215 );
216 v.insert(
217 "extra_gradle_dependencies",
218 render_extra_gradle_dependencies(&inputs.extra_gradle_dependencies),
219 );
220 v
221}
222
223fn render_extra_gradle_plugins(entries: &[String]) -> String {
235 if entries.is_empty() {
236 return String::new();
237 }
238 let mut out = String::new();
239 for entry in entries {
240 if entry.contains('(') {
241 out.push_str(&format!(" {entry}\n"));
242 } else {
243 out.push_str(&format!(" id(\"{entry}\")\n"));
244 }
245 }
246 if out.ends_with('\n') {
247 out.pop();
248 }
249 out
250}
251
252fn render_extra_gradle_dependencies(entries: &[String]) -> String {
253 if entries.is_empty() {
254 return String::new();
255 }
256 let mut out = String::new();
257 for entry in entries {
258 out.push_str(&format!(" {entry}\n"));
259 }
260 if out.ends_with('\n') {
261 out.pop();
262 }
263 out
264}
265
266fn render_extra_permissions(perms: &[String]) -> String {
270 if perms.is_empty() {
271 return String::new();
272 }
273 let mut seen = std::collections::BTreeSet::new();
274 let mut out = String::new();
275 for p in perms {
276 if seen.insert(p.as_str()) {
277 out.push_str(&format!(
278 " <uses-permission android:name=\"{}\" />\n",
279 escape_xml(p),
280 ));
281 }
282 }
283 if out.ends_with('\n') {
284 out.pop();
285 }
286 out
287}
288
289fn render_extra_meta_data(entries: &[MetaDataEntry]) -> String {
290 if entries.is_empty() {
291 return String::new();
292 }
293 let mut out = String::new();
294 for e in entries {
295 out.push_str(&format!(
296 " <meta-data android:name=\"{}\" android:value=\"{}\" />\n",
297 escape_xml(&e.name),
298 escape_xml(&e.value),
299 ));
300 }
301 if out.ends_with('\n') {
302 out.pop();
303 }
304 out
305}
306
307fn application_class_name(app_name: &str) -> String {
310 let cleaned: String = app_name
311 .chars()
312 .filter(|c| c.is_ascii_alphanumeric())
313 .collect();
314 if cleaned.is_empty() {
315 return "WhiskerApp_Application".into();
316 }
317 format!("{cleaned}Application")
318}
319
320fn project_name(app_name: &str) -> String {
324 let mut out = String::new();
325 for (i, c) in app_name.chars().enumerate() {
326 if c.is_ascii_uppercase() && i > 0 {
327 out.push('-');
328 }
329 out.extend(c.to_lowercase());
330 }
331 if out.is_empty() {
332 out.push_str("whisker-app");
333 }
334 format!("{out}-android")
335}
336
337fn application_id_to_path(application_id: &str) -> PathBuf {
340 application_id
341 .split('.')
342 .filter(|s| !s.is_empty())
343 .fold(PathBuf::new(), |acc, seg| acc.join(seg))
344}
345
346fn write_files(out_dir: &Path, inputs: &AndroidInputs) -> Result<()> {
347 let vars = template_vars(inputs);
348
349 clean_managed_tree(out_dir).context("clean previous gen tree")?;
355
356 let kotlin_pkg = out_dir
357 .join("app/src/main/kotlin")
358 .join(application_id_to_path(&inputs.application_id));
359
360 let app_class_filename = format!("{}.kt", application_class_name(&inputs.app_name));
361
362 let text_files: &[(PathBuf, &str)] = &[
364 (out_dir.join("app/build.gradle.kts"), APP_BUILD_GRADLE_KTS),
365 (
366 out_dir.join("app/src/main/AndroidManifest.xml"),
367 APP_MANIFEST_XML,
368 ),
369 (kotlin_pkg.join("MainActivity.kt"), MAIN_ACTIVITY_KT),
370 (kotlin_pkg.join(&app_class_filename), APPLICATION_KT),
371 (out_dir.join("build.gradle.kts"), ROOT_BUILD_GRADLE_KTS),
372 (out_dir.join("settings.gradle.kts"), SETTINGS_GRADLE_KTS),
373 (out_dir.join("gradle.properties"), GRADLE_PROPERTIES),
374 (
375 out_dir.join("gradle/wrapper/gradle-wrapper.properties"),
376 GRADLE_WRAPPER_PROPERTIES,
377 ),
378 ];
379 for (path, template) in text_files {
380 let rendered =
381 render(template, &vars).with_context(|| format!("render {}", path.display()))?;
382 write_file(path, rendered.as_bytes(), false)?;
383 }
384
385 write_file(&out_dir.join("gradlew"), GRADLEW.as_bytes(), true)?;
387 write_file(&out_dir.join("gradlew.bat"), GRADLEW_BAT.as_bytes(), false)?;
388
389 write_file(
391 &out_dir.join("gradle/wrapper/gradle-wrapper.jar"),
392 GRADLE_WRAPPER_JAR,
393 false,
394 )?;
395
396 for (rel, entry) in &inputs.extra_files {
401 crate::render::validate_extra_file_path(rel).with_context(|| {
402 format!(
403 "extra_files entry `{}` (Android plugin contribution)",
404 rel.display(),
405 )
406 })?;
407 let abs = out_dir.join(rel);
408 let executable = entry.mode.map(|m| m & 0o100 != 0).unwrap_or(false);
412 let bytes = entry
413 .to_bytes()
414 .with_context(|| format!("decode extra_files entry `{}` contents", rel.display()))?;
415 write_file(&abs, &bytes, executable)?;
416 }
417
418 Ok(())
419}
420
421fn clean_managed_tree(out_dir: &Path) -> Result<()> {
427 if !out_dir.exists() {
428 return Ok(());
429 }
430 let keep = ["app/build", ".gradle", "app/src/main/jniLibs"];
431 for entry in
432 std::fs::read_dir(out_dir).with_context(|| format!("read_dir {}", out_dir.display()))?
433 {
434 let entry = entry?;
435 let rel = entry
436 .path()
437 .strip_prefix(out_dir)
438 .map(|p| p.to_path_buf())
439 .ok();
440 if let Some(rel) = rel {
441 if keep.iter().any(|k| rel == Path::new(k)) {
442 continue;
443 }
444 }
445 if entry.file_name() == "app" && entry.path().is_dir() {
448 clean_under_app(&entry.path())?;
449 continue;
450 }
451 if entry.file_name() == ".whisker-fingerprint" {
453 continue;
454 }
455 remove_path(&entry.path())?;
456 }
457 Ok(())
458}
459
460fn clean_under_app(app_dir: &Path) -> Result<()> {
461 for entry in
462 std::fs::read_dir(app_dir).with_context(|| format!("read_dir {}", app_dir.display()))?
463 {
464 let entry = entry?;
465 if entry.file_name() == "build" {
467 continue;
468 }
469 if entry.path().is_dir() && entry.file_name() == "src" {
470 clean_under_src(&entry.path())?;
471 continue;
472 }
473 remove_path(&entry.path())?;
474 }
475 Ok(())
476}
477
478fn clean_under_src(src_dir: &Path) -> Result<()> {
479 for entry in
480 std::fs::read_dir(src_dir).with_context(|| format!("read_dir {}", src_dir.display()))?
481 {
482 let entry = entry?;
483 if entry.path().is_dir() && entry.file_name() == "main" {
484 clean_under_main(&entry.path())?;
485 continue;
486 }
487 remove_path(&entry.path())?;
488 }
489 Ok(())
490}
491
492fn clean_under_main(main_dir: &Path) -> Result<()> {
493 for entry in
494 std::fs::read_dir(main_dir).with_context(|| format!("read_dir {}", main_dir.display()))?
495 {
496 let entry = entry?;
497 if entry.file_name() == "jniLibs" {
499 continue;
500 }
501 remove_path(&entry.path())?;
502 }
503 Ok(())
504}
505
506fn remove_path(p: &Path) -> Result<()> {
507 if p.is_dir() {
508 std::fs::remove_dir_all(p).with_context(|| format!("rm -rf {}", p.display()))
509 } else {
510 std::fs::remove_file(p).with_context(|| format!("rm {}", p.display()))
511 }
512}
513
514fn write_file(path: &Path, bytes: &[u8], executable: bool) -> Result<()> {
515 if let Some(parent) = path.parent() {
516 std::fs::create_dir_all(parent)
517 .with_context(|| format!("mkdir -p {}", parent.display()))?;
518 }
519 std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
520 #[cfg(unix)]
521 if executable {
522 use std::os::unix::fs::PermissionsExt;
523 let mut perms = std::fs::metadata(path)?.permissions();
524 perms.set_mode(0o755);
525 std::fs::set_permissions(path, perms)?;
526 }
527 #[cfg(not(unix))]
528 let _ = executable;
529 Ok(())
530}
531
532#[allow(clippy::too_many_arguments)]
544pub fn inputs_from(
545 app_config: &Config,
546 rust_lib_name: String,
547 whisker_workspace_path: PathBuf,
548 whisker_user_package: String,
549 whisker_sdk_version: String,
550 whisker_gradle_plugin_version: String,
551 whisker_maven_url: String,
552 lynx_maven_url: String,
553) -> Result<AndroidInputs> {
554 inputs_from_with_engine(
555 &Engine::with_builtins(),
556 app_config,
557 rust_lib_name,
558 whisker_workspace_path,
559 whisker_user_package,
560 whisker_sdk_version,
561 whisker_gradle_plugin_version,
562 whisker_maven_url,
563 lynx_maven_url,
564 )
565}
566
567#[allow(clippy::too_many_arguments)]
571pub fn inputs_from_with_engine(
572 engine: &Engine,
573 app_config: &Config,
574 rust_lib_name: String,
575 whisker_workspace_path: PathBuf,
576 whisker_user_package: String,
577 whisker_sdk_version: String,
578 whisker_gradle_plugin_version: String,
579 whisker_maven_url: String,
580 lynx_maven_url: String,
581) -> Result<AndroidInputs> {
582 let ctx = engine
586 .compose(app_config, EnabledTargets::android_only())
587 .context("compose Whisker CNG plugin pipeline for Android")?;
588 let android_ir = ctx
589 .android
590 .as_ref()
591 .expect("EnabledTargets::android_only guarantees Some");
592
593 let app_name = android_ir
594 .app_name
595 .clone()
596 .ok_or_else(|| anyhow!("whisker.rs: app.name(\"…\") is required"))?;
597 let version = android_ir
598 .version
599 .clone()
600 .unwrap_or_else(|| "0.1.0".to_string());
601 let build_number = android_ir.build_number.unwrap_or(1);
602 let application_id = android_ir.application_id.clone().ok_or_else(|| {
603 anyhow!(
604 "whisker.rs: app.android(|a| a.application_id(\"…\")) (or app.bundle_id) is required for Android"
605 )
606 })?;
607 let min_sdk = android_ir.min_sdk.unwrap_or(24);
608 let target_sdk = android_ir.target_sdk.unwrap_or(34);
609
610 let extra_permissions = android_ir.manifest.permissions.clone();
611 let extra_meta_data = android_ir.manifest.application_meta_data.clone();
612 let extra_gradle_plugins = android_ir.gradle.apply_plugins.clone();
613 let extra_gradle_dependencies = android_ir.gradle.dependencies.clone();
614 let extra_files = android_ir.extra_files.clone();
615
616 Ok(AndroidInputs {
617 app_name,
618 version,
619 build_number,
620 application_id,
621 min_sdk,
622 target_sdk,
623 rust_lib_name,
624 whisker_workspace_path,
625 whisker_user_package,
626 whisker_sdk_version,
627 whisker_gradle_plugin_version,
628 whisker_maven_url,
629 lynx_maven_url,
630 extra_permissions,
631 extra_meta_data,
632 extra_gradle_plugins,
633 extra_gradle_dependencies,
634 extra_files,
635 template_version: 9,
639 })
640}
641
642#[cfg(test)]
647mod tests {
648 use super::*;
649 use std::sync::atomic::{AtomicU64, Ordering};
650
651 fn unique_tempdir() -> PathBuf {
652 static SEQ: AtomicU64 = AtomicU64::new(0);
653 let n = SEQ.fetch_add(1, Ordering::Relaxed);
654 let pid = std::process::id();
655 let p = std::env::temp_dir().join(format!("whisker-cng-android-test-{pid}-{n}"));
656 std::fs::create_dir_all(&p).unwrap();
657 p
658 }
659
660 fn sample_inputs() -> AndroidInputs {
661 AndroidInputs {
662 app_name: "HelloWorld".into(),
663 version: "0.1.0".into(),
664 build_number: 1,
665 application_id: "rs.whisker.examples.helloworld".into(),
666 min_sdk: 24,
667 target_sdk: 34,
668 rust_lib_name: "hello_world".into(),
669 whisker_workspace_path: PathBuf::from("../.."),
670 whisker_user_package: "hello-world".into(),
671 whisker_sdk_version: "0.1.0".into(),
672 whisker_gradle_plugin_version: "0.1.0".into(),
673 whisker_maven_url: "https://whiskerrs.github.io/whisker/maven".into(),
674 lynx_maven_url: "https://whiskerrs.github.io/lynx/maven".into(),
675 extra_permissions: Vec::new(),
676 extra_meta_data: Vec::new(),
677 extra_gradle_plugins: Vec::new(),
678 extra_gradle_dependencies: Vec::new(),
679 extra_files: BTreeMap::new(),
680 template_version: 9,
681 }
682 }
683
684 #[test]
685 fn extra_files_writes_binary_contents_via_base64() {
686 let mut inputs = sample_inputs();
689 let raw = vec![0x00u8, 0x01, 0xfe, 0xff];
690 inputs.extra_files.insert(
691 PathBuf::from("app/src/main/assets/whisker/images/logo.png"),
692 FileEntry::binary(&raw),
693 );
694 let tmp = unique_tempdir();
695 let out = tmp.join("gen/android");
696 sync(&out, &inputs).unwrap();
697 let written =
698 std::fs::read(out.join("app/src/main/assets/whisker/images/logo.png")).unwrap();
699 assert_eq!(written, raw);
700 let _ = std::fs::remove_dir_all(&tmp);
701 }
702
703 #[test]
704 fn template_vars_carry_required_keys() {
705 let inputs = sample_inputs();
706 let vars = template_vars(&inputs);
707 assert_eq!(
708 vars["android_application_id"],
709 "rs.whisker.examples.helloworld"
710 );
711 assert_eq!(vars["android_application_class"], "HelloWorldApplication");
712 assert_eq!(vars["android_min_sdk"], "24");
713 assert_eq!(vars["android_target_sdk"], "34");
714 assert_eq!(vars["rust_lib_name"], "hello_world");
715 assert_eq!(vars["build_number"], "1");
716 assert_eq!(vars["version"], "0.1.0");
717 }
718
719 #[test]
720 fn application_class_strips_punctuation() {
721 assert_eq!(
722 application_class_name("Hello World"),
723 "HelloWorldApplication"
724 );
725 assert_eq!(application_class_name("My-App"), "MyAppApplication");
726 }
727
728 #[test]
729 fn project_name_lowercases_and_appends_android_suffix() {
730 assert_eq!(project_name("HelloWorld"), "hello-world-android");
731 }
732
733 #[test]
734 fn application_id_to_path_splits_on_dots() {
735 assert_eq!(
736 application_id_to_path("rs.whisker.examples.helloworld"),
737 PathBuf::from("rs/whisker/examples/helloworld"),
738 );
739 }
740
741 #[test]
742 fn sync_writes_known_files_to_out_dir() {
743 let tmp = unique_tempdir();
744 let out = tmp.join("gen/android");
745 let regenerated = sync(&out, &sample_inputs()).expect("sync");
746 assert!(regenerated);
747
748 for expected in [
749 "app/build.gradle.kts",
750 "app/src/main/AndroidManifest.xml",
751 "app/src/main/kotlin/rs/whisker/examples/helloworld/MainActivity.kt",
752 "app/src/main/kotlin/rs/whisker/examples/helloworld/HelloWorldApplication.kt",
753 "build.gradle.kts",
754 "settings.gradle.kts",
755 "gradle.properties",
756 "gradlew",
757 "gradlew.bat",
758 "gradle/wrapper/gradle-wrapper.properties",
759 "gradle/wrapper/gradle-wrapper.jar",
760 ".whisker-fingerprint",
761 ] {
762 assert!(out.join(expected).exists(), "missing: {expected}");
763 }
764
765 let _ = std::fs::remove_dir_all(&tmp);
766 }
767
768 #[test]
769 fn sync_substitutes_placeholders_in_generated_files() {
770 let tmp = unique_tempdir();
771 let out = tmp.join("gen/android");
772 sync(&out, &sample_inputs()).unwrap();
773
774 let manifest =
775 std::fs::read_to_string(out.join("app/src/main/AndroidManifest.xml")).unwrap();
776 assert!(manifest.contains("android:name=\".HelloWorldApplication\""));
777 assert!(manifest.contains("android:label=\"HelloWorld\""));
778 assert!(!manifest.contains("{{"));
779
780 let main_activity = std::fs::read_to_string(
781 out.join("app/src/main/kotlin/rs/whisker/examples/helloworld/MainActivity.kt"),
782 )
783 .unwrap();
784 assert!(main_activity.starts_with("package rs.whisker.examples.helloworld\n"));
785
786 let _ = std::fs::remove_dir_all(&tmp);
787 }
788
789 #[test]
790 fn sync_is_idempotent_when_fingerprint_matches() {
791 let tmp = unique_tempdir();
792 let out = tmp.join("gen/android");
793 let first = sync(&out, &sample_inputs()).unwrap();
794 assert!(first);
795 let second = sync(&out, &sample_inputs()).unwrap();
796 assert!(!second, "second sync should be a no-op");
797
798 let _ = std::fs::remove_dir_all(&tmp);
799 }
800
801 #[test]
802 fn sync_regenerates_when_inputs_change() {
803 let tmp = unique_tempdir();
804 let out = tmp.join("gen/android");
805 sync(&out, &sample_inputs()).unwrap();
806 let mut next = sample_inputs();
807 next.target_sdk = 35;
808 let regenerated = sync(&out, &next).unwrap();
809 assert!(regenerated);
810 let app_gradle = std::fs::read_to_string(out.join("app/build.gradle.kts")).unwrap();
811 assert!(app_gradle.contains("compileSdk = 35"));
812
813 let _ = std::fs::remove_dir_all(&tmp);
814 }
815
816 #[test]
817 fn sync_preserves_jnilibs_across_regeneration() {
818 let tmp = unique_tempdir();
820 let out = tmp.join("gen/android");
821 sync(&out, &sample_inputs()).unwrap();
822 let jni = out.join("app/src/main/jniLibs/arm64-v8a");
823 std::fs::create_dir_all(&jni).unwrap();
824 let dylib = jni.join("libhello_world.so");
825 std::fs::write(&dylib, b"FAKE_DYLIB").unwrap();
826
827 let mut next = sample_inputs();
828 next.min_sdk = 25;
829 sync(&out, &next).unwrap();
830 assert!(dylib.exists(), "dylib was wiped by re-sync");
831 assert_eq!(std::fs::read(&dylib).unwrap(), b"FAKE_DYLIB");
832
833 let _ = std::fs::remove_dir_all(&tmp);
834 }
835
836 #[test]
837 fn inputs_from_errors_when_application_id_unset() {
838 let cfg = Config {
839 name: Some("X".into()),
840 ..Config::default()
841 };
842 let err = inputs_from(
843 &cfg,
844 "x".into(),
845 PathBuf::new(),
846 "x".into(),
847 "0.1.0".into(),
848 "0.1.0".into(),
849 "https://whiskerrs.github.io/whisker/maven".into(),
850 "https://whiskerrs.github.io/lynx/maven".into(),
851 )
852 .unwrap_err();
853 assert!(err.to_string().contains("application_id"), "got: {err:#}");
854 }
855}