1use std::env;
21use std::ffi::{OsStr, OsString};
22use std::path::{Path, PathBuf};
23#[cfg(all(test, target_os = "macos"))]
24use std::process::ExitStatus;
25use std::process::{Command, Output};
26use std::time::Duration;
27
28use mobench_process::{
29 DeclaredExecutable, EnvironmentPolicy, ProcessLimits, ProcessRunner, ProcessSpec,
30 WorkingDirectoryPolicy,
31};
32use serde::Deserialize;
33
34use crate::types::BenchError;
35
36#[derive(Deserialize)]
37struct CargoMetadata {
38 target_directory: String,
39}
40
41const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(30 * 60);
42const TOOL_OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
43
44#[derive(Debug, Clone)]
46pub(crate) struct ToolCommand {
47 executable: DeclaredExecutable,
48 arguments: Vec<OsString>,
49 working_directory: WorkingDirectoryPolicy,
50 environment: EnvironmentPolicy,
51 timeout: Duration,
52}
53
54impl ToolCommand {
55 pub(crate) fn path_search(name: &'static str) -> Self {
56 Self {
57 executable: DeclaredExecutable::path_search(name)
58 .expect("fixed tool name must be one PATH-search component"),
59 arguments: Vec::new(),
60 working_directory: WorkingDirectoryPolicy::Inherit,
61 environment: EnvironmentPolicy::Inherit,
62 timeout: DEFAULT_TOOL_TIMEOUT,
63 }
64 }
65
66 pub(crate) fn explicit(path: impl AsRef<Path>) -> Result<Self, BenchError> {
67 let path = path.as_ref();
68 let executable = DeclaredExecutable::explicit_override(path).map_err(|error| {
69 BenchError::Build(format!(
70 "Invalid explicit tool path {}: {error}",
71 path.display()
72 ))
73 })?;
74 Ok(Self {
75 executable,
76 arguments: Vec::new(),
77 working_directory: WorkingDirectoryPolicy::Inherit,
78 environment: EnvironmentPolicy::Inherit,
79 timeout: DEFAULT_TOOL_TIMEOUT,
80 })
81 }
82
83 pub(crate) fn arg(&mut self, argument: impl AsRef<OsStr>) -> &mut Self {
84 self.arguments.push(argument.as_ref().to_os_string());
85 self
86 }
87
88 pub(crate) fn args<I, S>(&mut self, arguments: I) -> &mut Self
89 where
90 I: IntoIterator<Item = S>,
91 S: AsRef<OsStr>,
92 {
93 self.arguments.extend(
94 arguments
95 .into_iter()
96 .map(|argument| argument.as_ref().to_os_string()),
97 );
98 self
99 }
100
101 pub(crate) fn current_dir(&mut self, path: impl AsRef<Path>) -> &mut Self {
102 self.working_directory = WorkingDirectoryPolicy::Path(path.as_ref().to_path_buf());
103 self
104 }
105
106 pub(crate) fn timeout(&mut self, timeout: Duration) -> &mut Self {
107 self.timeout = timeout;
108 self
109 }
110
111 pub(crate) fn output(&self) -> Result<Output, BenchError> {
112 let spec = ProcessSpec::new(
113 self.executable.clone(),
114 self.arguments.clone(),
115 self.working_directory.clone(),
116 self.environment.clone(),
117 ProcessLimits::new(self.timeout, TOOL_OUTPUT_LIMIT, TOOL_OUTPUT_LIMIT),
118 );
119 let outcome = ProcessRunner::run(&spec)
120 .map_err(|error| BenchError::Build(format!("Failed to run external tool: {error}")))?;
121 if outcome.cancelled {
122 return Err(BenchError::Build(
123 "External tool was interrupted".to_owned(),
124 ));
125 }
126 outcome.into_complete_output().map_err(|error| {
127 BenchError::Build(format!(
128 "External tool output exceeded the complete-capture contract: {error}"
129 ))
130 })
131 }
132
133 #[cfg(all(test, target_os = "macos"))]
134 pub(crate) fn status(&self) -> Result<ExitStatus, BenchError> {
135 self.output().map(|output| output.status)
136 }
137}
138
139pub fn validate_project_root(project_root: &Path, crate_name: &str) -> Result<(), BenchError> {
153 if !project_root.exists() {
155 return Err(BenchError::Build(format!(
156 "Project root does not exist: {}\n\n\
157 Ensure you are running from the correct directory or specify --project-root.",
158 project_root.display()
159 )));
160 }
161
162 if !project_root.is_dir() {
164 return Err(BenchError::Build(format!(
165 "Project root is not a directory: {}\n\n\
166 Expected a directory containing your Rust project.",
167 project_root.display()
168 )));
169 }
170
171 let root_cargo = project_root.join("Cargo.toml");
173 let bench_mobile_cargo = project_root.join("bench-mobile/Cargo.toml");
174 let crates_cargo = project_root.join(format!("crates/{}/Cargo.toml", crate_name));
175
176 if !root_cargo.exists() && !bench_mobile_cargo.exists() && !crates_cargo.exists() {
177 return Err(BenchError::Build(format!(
178 "No Cargo.toml found in project root or expected crate locations.\n\n\
179 Searched:\n\
180 - {}\n\
181 - {}\n\
182 - {}\n\n\
183 Ensure you are in a Rust project directory or use --crate-path to specify the crate location.",
184 root_cargo.display(),
185 bench_mobile_cargo.display(),
186 crates_cargo.display()
187 )));
188 }
189
190 Ok(())
191}
192
193pub fn get_cargo_target_dir(crate_dir: &Path) -> Result<PathBuf, BenchError> {
208 let mut command = ToolCommand::path_search("cargo");
209 command
210 .args(["metadata", "--format-version", "1", "--no-deps"])
211 .current_dir(crate_dir)
212 .timeout(Duration::from_secs(30));
213 let output = command.output().map_err(|e| {
214 BenchError::Build(format!(
215 "Failed to run cargo metadata.\n\n\
216 Working directory: {}\n\
217 Error: {}\n\n\
218 Ensure cargo is installed and on PATH.",
219 crate_dir.display(),
220 e
221 ))
222 })?;
223
224 if !output.status.success() {
225 let fallback = crate_dir.join("target");
227 let stderr = String::from_utf8_lossy(&output.stderr);
228 eprintln!(
229 "Warning: cargo metadata failed (exit {}), falling back to {}.\n\
230 Stderr: {}\n\
231 This may cause build issues if you are in a Cargo workspace.",
232 output.status,
233 fallback.display(),
234 stderr.lines().take(3).collect::<Vec<_>>().join("\n")
235 );
236 return Ok(fallback);
237 }
238
239 match serde_json::from_slice::<CargoMetadata>(&output.stdout) {
240 Ok(metadata) => return Ok(PathBuf::from(metadata.target_directory)),
241 Err(err) => eprintln!(
242 "Warning: Failed to parse cargo metadata JSON ({}). Falling back to crate-local target dir.",
243 err
244 ),
245 }
246
247 let fallback = crate_dir.join("target");
249 eprintln!(
250 "Warning: Failed to parse target_directory from cargo metadata output, \
251 falling back to {}.\n\
252 This may cause build issues if you are in a Cargo workspace.",
253 fallback.display()
254 );
255 Ok(fallback)
256}
257
258pub fn host_lib_path(crate_dir: &Path, crate_name: &str) -> Result<PathBuf, BenchError> {
270 let lib_prefix = if cfg!(target_os = "windows") {
271 ""
272 } else {
273 "lib"
274 };
275 let lib_ext = match env::consts::OS {
276 "macos" => "dylib",
277 "linux" => "so",
278 other => {
279 return Err(BenchError::Build(format!(
280 "Unsupported host OS for binding generation: {}\n\n\
281 Supported platforms:\n\
282 - macOS (generates .dylib)\n\
283 - Linux (generates .so)\n\n\
284 Windows is not currently supported for binding generation.",
285 other
286 )));
287 }
288 };
289
290 let target_dir = get_cargo_target_dir(crate_dir)?;
292
293 let lib_name = format!("{}{}.{}", lib_prefix, crate_name.replace('-', "_"), lib_ext);
294 let path = target_dir.join("debug").join(&lib_name);
295
296 if !path.exists() {
297 return Err(BenchError::Build(format!(
298 "Host library for UniFFI not found.\n\n\
299 Expected: {}\n\
300 Target directory: {}\n\n\
301 To fix this:\n\
302 1. Build the host library first:\n\
303 cargo build -p {}\n\n\
304 2. Ensure your crate produces a cdylib:\n\
305 [lib]\n\
306 crate-type = [\"cdylib\"]\n\n\
307 3. Check that the library name matches: {}",
308 path.display(),
309 target_dir.display(),
310 crate_name,
311 lib_name
312 )));
313 }
314 Ok(path)
315}
316
317pub(crate) fn run_tool_command(cmd: ToolCommand, description: &str) -> Result<(), BenchError> {
329 let output = cmd.output().map_err(|e| {
330 BenchError::Build(format!(
331 "Failed to start {}.\n\n\
332 Error: {}\n\n\
333 Ensure the tool is installed and available on PATH.",
334 description, e
335 ))
336 })?;
337
338 if !output.status.success() {
339 let stdout = String::from_utf8_lossy(&output.stdout);
340 let stderr = String::from_utf8_lossy(&output.stderr);
341 return Err(BenchError::Build(format!(
342 "{} failed.\n\n\
343 Exit status: {}\n\n\
344 Stdout:\n{}\n\n\
345 Stderr:\n{}",
346 description, output.status, stdout, stderr
347 )));
348 }
349 Ok(())
350}
351
352pub fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
360 let output = cmd.output().map_err(|e| {
361 BenchError::Build(format!(
362 "Failed to start {}.\n\n\
363 Error: {}\n\n\
364 Ensure the tool is installed and available on PATH.",
365 description, e
366 ))
367 })?;
368
369 if !output.status.success() {
370 let stdout = String::from_utf8_lossy(&output.stdout);
371 let stderr = String::from_utf8_lossy(&output.stderr);
372 return Err(BenchError::Build(format!(
373 "{} failed.\n\n\
374 Exit status: {}\n\n\
375 Stdout:\n{}\n\n\
376 Stderr:\n{}",
377 description, output.status, stdout, stderr
378 )));
379 }
380 Ok(())
381}
382
383pub fn read_package_name(cargo_toml_path: &Path) -> Option<String> {
403 let content = std::fs::read_to_string(cargo_toml_path).ok()?;
404
405 let package_start = content.find("[package]")?;
407 let package_section = &content[package_start..];
408
409 let section_end = package_section[1..]
411 .find("\n[")
412 .map(|i| i + 1)
413 .unwrap_or(package_section.len());
414 let package_section = &package_section[..section_end];
415
416 for line in package_section.lines() {
418 let trimmed = line.trim();
419 if trimmed.starts_with("name") {
420 if let Some(eq_pos) = trimmed.find('=') {
422 let value_part = trimmed[eq_pos + 1..].trim();
423 let (quote_char, start) = if value_part.starts_with('"') {
425 ('"', 1)
426 } else if value_part.starts_with('\'') {
427 ('\'', 1)
428 } else {
429 continue;
430 };
431 if let Some(end) = value_part[start..].find(quote_char) {
432 return Some(value_part[start..start + end].to_string());
433 }
434 }
435 }
436 }
437
438 None
439}
440
441pub fn embed_bench_spec<S: serde::Serialize>(
465 output_dir: &Path,
466 spec: &S,
467) -> Result<(), BenchError> {
468 let spec_value = serde_json::to_value(spec)
469 .map_err(|e| BenchError::Build(format!("Failed to serialize bench spec: {}", e)))?;
470 let spec_json = serde_json::to_string_pretty(&spec_value)
471 .map_err(|e| BenchError::Build(format!("Failed to serialize bench spec: {}", e)))?;
472
473 for spec_path in [
476 output_dir.join("target/mobile-spec/android/bench_spec.json"),
477 output_dir.join("target/mobile-spec/ios/bench_spec.json"),
478 ] {
479 if let Some(parent) = spec_path.parent() {
480 std::fs::create_dir_all(parent).map_err(|e| {
481 BenchError::Build(format!(
482 "Failed to create bench spec directory at {}: {}",
483 parent.display(),
484 e
485 ))
486 })?;
487 }
488 std::fs::write(&spec_path, &spec_json).map_err(|e| {
489 BenchError::Build(format!(
490 "Failed to write bench spec to {}: {}",
491 spec_path.display(),
492 e
493 ))
494 })?;
495 }
496
497 let android_assets_dir = output_dir.join("android/app/src/main/assets");
499 if output_dir.join("android").exists() {
500 std::fs::create_dir_all(&android_assets_dir).map_err(|e| {
501 BenchError::Build(format!(
502 "Failed to create Android assets directory at {}: {}",
503 android_assets_dir.display(),
504 e
505 ))
506 })?;
507 let android_spec_path = android_assets_dir.join("bench_spec.json");
508 std::fs::write(&android_spec_path, &spec_json).map_err(|e| {
509 BenchError::Build(format!(
510 "Failed to write Android bench spec to {}: {}",
511 android_spec_path.display(),
512 e
513 ))
514 })?;
515 }
516
517 let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources");
519 if output_dir.join("ios/BenchRunner").exists() {
520 std::fs::create_dir_all(&ios_resources_dir).map_err(|e| {
521 BenchError::Build(format!(
522 "Failed to create iOS Resources directory at {}: {}",
523 ios_resources_dir.display(),
524 e
525 ))
526 })?;
527 let ios_spec_path = ios_resources_dir.join("bench_spec.json");
528 std::fs::write(&ios_spec_path, &spec_json).map_err(|e| {
529 BenchError::Build(format!(
530 "Failed to write iOS bench spec to {}: {}",
531 ios_spec_path.display(),
532 e
533 ))
534 })?;
535
536 if let Some(function) = spec_value
537 .get("function")
538 .and_then(serde_json::Value::as_str)
539 {
540 bind_ios_xcuitest_to_requested_function(output_dir, function)?;
541 }
542 }
543
544 Ok(())
545}
546
547fn bind_ios_xcuitest_to_requested_function(
548 output_dir: &Path,
549 function: &str,
550) -> Result<(), BenchError> {
551 const EXPECTED_FUNCTION_PREFIX: &str = "private let expectedBenchmarkFunction = ";
552
553 let test_source =
554 output_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
555 if !test_source.exists() {
556 return Err(BenchError::Build(format!(
557 "Generated iOS XCUITest source is missing at {}",
558 test_source.display()
559 )));
560 }
561
562 let contents = std::fs::read_to_string(&test_source).map_err(|e| {
563 BenchError::Build(format!(
564 "Failed to read iOS XCUITest source at {}: {}",
565 test_source.display(),
566 e
567 ))
568 })?;
569 let escaped_function: String = function.chars().flat_map(char::escape_default).collect();
570 let mut replacements = 0usize;
571 let mut updated = String::with_capacity(contents.len() + escaped_function.len());
572
573 for line in contents.split_inclusive('\n') {
574 let line_without_newline = line.strip_suffix('\n').unwrap_or(line);
575 let trimmed = line_without_newline.trim_start();
576 if trimmed.starts_with(EXPECTED_FUNCTION_PREFIX) {
577 let indentation = &line_without_newline[..line_without_newline.len() - trimmed.len()];
578 updated.push_str(indentation);
579 updated.push_str(EXPECTED_FUNCTION_PREFIX);
580 updated.push('"');
581 updated.push_str(&escaped_function);
582 updated.push('"');
583 replacements += 1;
584 } else {
585 updated.push_str(line_without_newline);
586 }
587 if line.ends_with('\n') {
588 updated.push('\n');
589 }
590 }
591
592 if replacements != 1 {
593 return Err(BenchError::Build(format!(
594 "Expected exactly one XCUITest benchmark-function binding in {}, found {}",
595 test_source.display(),
596 replacements
597 )));
598 }
599
600 std::fs::write(&test_source, updated).map_err(|e| {
601 BenchError::Build(format!(
602 "Failed to bind iOS XCUITest to requested function in {}: {}",
603 test_source.display(),
604 e
605 ))
606 })
607}
608
609#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
614pub struct EmbeddedBenchSpec {
615 pub function: String,
617 pub iterations: u32,
619 pub warmup: u32,
621}
622
623#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
628pub struct BenchMeta {
629 pub spec: EmbeddedBenchSpec,
631 #[serde(skip_serializing_if = "Option::is_none")]
633 pub commit_hash: Option<String>,
634 #[serde(skip_serializing_if = "Option::is_none")]
636 pub branch: Option<String>,
637 #[serde(skip_serializing_if = "Option::is_none")]
639 pub dirty: Option<bool>,
640 pub build_time: String,
642 pub build_time_unix: u64,
644 pub target: String,
646 pub profile: String,
648 pub mobench_version: String,
650 #[serde(skip_serializing_if = "Option::is_none")]
652 pub rust_version: Option<String>,
653 pub host_os: String,
655}
656
657pub fn get_git_commit() -> Option<String> {
659 let mut command = ToolCommand::path_search("git");
660 command
661 .args(["rev-parse", "--short", "HEAD"])
662 .timeout(Duration::from_secs(30));
663 let output = command.output().ok()?;
664
665 if output.status.success() {
666 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
667 if !hash.is_empty() {
668 return Some(hash);
669 }
670 }
671 None
672}
673
674pub fn get_git_branch() -> Option<String> {
676 let mut command = ToolCommand::path_search("git");
677 command
678 .args(["rev-parse", "--abbrev-ref", "HEAD"])
679 .timeout(Duration::from_secs(30));
680 let output = command.output().ok()?;
681
682 if output.status.success() {
683 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
684 if !branch.is_empty() && branch != "HEAD" {
685 return Some(branch);
686 }
687 }
688 None
689}
690
691pub fn is_git_dirty() -> Option<bool> {
693 let mut command = ToolCommand::path_search("git");
694 command
695 .args(["status", "--porcelain"])
696 .timeout(Duration::from_secs(30));
697 let output = command.output().ok()?;
698
699 if output.status.success() {
700 let status = String::from_utf8_lossy(&output.stdout);
701 Some(!status.trim().is_empty())
702 } else {
703 None
704 }
705}
706
707pub fn get_rust_version() -> Option<String> {
709 let mut command = ToolCommand::path_search("rustc");
710 command.arg("--version").timeout(Duration::from_secs(30));
711 let output = command.output().ok()?;
712
713 if output.status.success() {
714 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
715 if !version.is_empty() {
716 return Some(version);
717 }
718 }
719 None
720}
721
722pub fn create_bench_meta(spec: &EmbeddedBenchSpec, target: &str, profile: &str) -> BenchMeta {
724 use std::time::{SystemTime, UNIX_EPOCH};
725
726 let now = SystemTime::now()
727 .duration_since(UNIX_EPOCH)
728 .unwrap_or_default();
729
730 let build_time = {
732 let secs = now.as_secs();
733 let days_since_epoch = secs / 86400;
735 let remaining_secs = secs % 86400;
736 let hours = remaining_secs / 3600;
737 let minutes = (remaining_secs % 3600) / 60;
738 let seconds = remaining_secs % 60;
739
740 let (year, month, day) = days_to_ymd(days_since_epoch);
743
744 format!(
745 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
746 year, month, day, hours, minutes, seconds
747 )
748 };
749
750 BenchMeta {
751 spec: spec.clone(),
752 commit_hash: get_git_commit(),
753 branch: get_git_branch(),
754 dirty: is_git_dirty(),
755 build_time,
756 build_time_unix: now.as_secs(),
757 target: target.to_string(),
758 profile: profile.to_string(),
759 mobench_version: env!("CARGO_PKG_VERSION").to_string(),
760 rust_version: get_rust_version(),
761 host_os: env::consts::OS.to_string(),
762 }
763}
764
765fn days_to_ymd(days: u64) -> (i32, u32, u32) {
768 let mut remaining_days = days as i64;
769 let mut year = 1970i32;
770
771 loop {
773 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
774 if remaining_days < days_in_year {
775 break;
776 }
777 remaining_days -= days_in_year;
778 year += 1;
779 }
780
781 let days_in_months: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
783
784 let mut month = 1u32;
785 for (i, &days_in_month) in days_in_months.iter().enumerate() {
786 let mut dim = days_in_month;
787 if i == 1 && is_leap_year(year) {
788 dim = 29;
789 }
790 if remaining_days < dim {
791 break;
792 }
793 remaining_days -= dim;
794 month += 1;
795 }
796
797 (year, month, remaining_days as u32 + 1)
798}
799
800fn is_leap_year(year: i32) -> bool {
801 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
802}
803
804pub fn embed_bench_meta(
819 output_dir: &Path,
820 spec: &EmbeddedBenchSpec,
821 target: &str,
822 profile: &str,
823) -> Result<(), BenchError> {
824 let meta = create_bench_meta(spec, target, profile);
825 let meta_json = serde_json::to_string_pretty(&meta)
826 .map_err(|e| BenchError::Build(format!("Failed to serialize bench meta: {}", e)))?;
827
828 let android_assets_dir = output_dir.join("android/app/src/main/assets");
830 if output_dir.join("android").exists() {
831 std::fs::create_dir_all(&android_assets_dir).map_err(|e| {
832 BenchError::Build(format!(
833 "Failed to create Android assets directory at {}: {}",
834 android_assets_dir.display(),
835 e
836 ))
837 })?;
838 let android_meta_path = android_assets_dir.join("bench_meta.json");
839 std::fs::write(&android_meta_path, &meta_json).map_err(|e| {
840 BenchError::Build(format!(
841 "Failed to write Android bench meta to {}: {}",
842 android_meta_path.display(),
843 e
844 ))
845 })?;
846 }
847
848 let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources");
850 if output_dir.join("ios/BenchRunner").exists() {
851 std::fs::create_dir_all(&ios_resources_dir).map_err(|e| {
852 BenchError::Build(format!(
853 "Failed to create iOS Resources directory at {}: {}",
854 ios_resources_dir.display(),
855 e
856 ))
857 })?;
858 let ios_meta_path = ios_resources_dir.join("bench_meta.json");
859 std::fs::write(&ios_meta_path, &meta_json).map_err(|e| {
860 BenchError::Build(format!(
861 "Failed to write iOS bench meta to {}: {}",
862 ios_meta_path.display(),
863 e
864 ))
865 })?;
866 }
867
868 Ok(())
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
876 fn test_get_cargo_target_dir_fallback() {
877 let result = get_cargo_target_dir(Path::new("/nonexistent/path"));
879 assert!(result.is_ok() || result.is_err());
881 }
882
883 #[test]
884 fn test_host_lib_path_not_found() {
885 let result = host_lib_path(Path::new("/tmp"), "nonexistent-crate");
886 assert!(result.is_err());
887 let err = result.unwrap_err();
888 let msg = format!("{}", err);
889 assert!(msg.contains("Host library for UniFFI not found"));
890 assert!(msg.contains("cargo build"));
891 }
892
893 #[test]
894 fn test_run_command_not_found() {
895 let cmd = ToolCommand::path_search("nonexistent-command-12345");
896 let result = run_tool_command(cmd, "test command");
897 assert!(result.is_err());
898 let err = result.unwrap_err();
899 let msg = format!("{}", err);
900 assert!(msg.contains("Failed to start"));
901 }
902
903 #[cfg(unix)]
904 #[test]
905 fn public_run_command_preserves_env_clear() {
906 let mut cmd = Command::new("/bin/sh");
907 cmd.env_clear()
908 .env("MOBENCH_EXPLICIT_ENV", "present")
909 .args([
910 "-c",
911 "test \"$MOBENCH_EXPLICIT_ENV\" = present && test -z \"${HOME+x}\"",
912 ]);
913
914 run_command(cmd, "env-clear compatibility check").unwrap();
915 }
916
917 #[test]
918 fn test_read_package_name_standard() {
919 let temp_dir = std::env::temp_dir().join("mobench-test-read-package");
920 let _ = std::fs::remove_dir_all(&temp_dir);
921 std::fs::create_dir_all(&temp_dir).unwrap();
922
923 let cargo_toml = temp_dir.join("Cargo.toml");
924 std::fs::write(
925 &cargo_toml,
926 r#"[package]
927name = "my-awesome-crate"
928version = "0.1.0"
929edition = "2021"
930
931[dependencies]
932"#,
933 )
934 .unwrap();
935
936 let result = read_package_name(&cargo_toml);
937 assert_eq!(result, Some("my-awesome-crate".to_string()));
938
939 std::fs::remove_dir_all(&temp_dir).unwrap();
940 }
941
942 #[test]
943 fn test_read_package_name_with_single_quotes() {
944 let temp_dir = std::env::temp_dir().join("mobench-test-read-package-sq");
945 let _ = std::fs::remove_dir_all(&temp_dir);
946 std::fs::create_dir_all(&temp_dir).unwrap();
947
948 let cargo_toml = temp_dir.join("Cargo.toml");
949 std::fs::write(
950 &cargo_toml,
951 r#"[package]
952name = 'single-quoted-crate'
953version = "0.1.0"
954"#,
955 )
956 .unwrap();
957
958 let result = read_package_name(&cargo_toml);
959 assert_eq!(result, Some("single-quoted-crate".to_string()));
960
961 std::fs::remove_dir_all(&temp_dir).unwrap();
962 }
963
964 #[test]
965 fn test_read_package_name_not_found() {
966 let result = read_package_name(Path::new("/nonexistent/Cargo.toml"));
967 assert_eq!(result, None);
968 }
969
970 #[test]
971 fn test_read_package_name_no_package_section() {
972 let temp_dir = std::env::temp_dir().join("mobench-test-read-package-no-pkg");
973 let _ = std::fs::remove_dir_all(&temp_dir);
974 std::fs::create_dir_all(&temp_dir).unwrap();
975
976 let cargo_toml = temp_dir.join("Cargo.toml");
977 std::fs::write(
978 &cargo_toml,
979 r#"[workspace]
980members = ["crates/*"]
981"#,
982 )
983 .unwrap();
984
985 let result = read_package_name(&cargo_toml);
986 assert_eq!(result, None);
987
988 std::fs::remove_dir_all(&temp_dir).unwrap();
989 }
990
991 #[test]
992 fn test_create_bench_meta() {
993 let spec = EmbeddedBenchSpec {
994 function: "test_crate::my_benchmark".to_string(),
995 iterations: 100,
996 warmup: 10,
997 };
998
999 let meta = create_bench_meta(&spec, "android", "release");
1000
1001 assert_eq!(meta.spec.function, "test_crate::my_benchmark");
1002 assert_eq!(meta.spec.iterations, 100);
1003 assert_eq!(meta.spec.warmup, 10);
1004 assert_eq!(meta.target, "android");
1005 assert_eq!(meta.profile, "release");
1006 assert!(!meta.mobench_version.is_empty());
1007 assert!(!meta.host_os.is_empty());
1008 assert!(!meta.build_time.is_empty());
1009 assert!(meta.build_time_unix > 0);
1010 assert!(meta.build_time.contains('T'));
1012 assert!(meta.build_time.ends_with('Z'));
1013 }
1014
1015 #[test]
1016 fn embed_bench_spec_writes_first_run_mobile_spec_locations() {
1017 let temp_dir =
1018 std::env::temp_dir().join(format!("mobench-test-embed-spec-{}", std::process::id()));
1019 let _ = std::fs::remove_dir_all(&temp_dir);
1020 std::fs::create_dir_all(&temp_dir).unwrap();
1021
1022 #[derive(serde::Serialize)]
1023 struct AndroidSpec {
1024 function: String,
1025 iterations: u32,
1026 warmup: u32,
1027 android_benchmark_timeout_secs: Option<u64>,
1028 android_heartbeat_interval_secs: Option<u64>,
1029 }
1030
1031 let spec = AndroidSpec {
1032 function: "test_crate::first_run".to_string(),
1033 iterations: 7,
1034 warmup: 1,
1035 android_benchmark_timeout_secs: Some(30),
1036 android_heartbeat_interval_secs: Some(5),
1037 };
1038
1039 let ios_test_source =
1040 temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift");
1041 std::fs::create_dir_all(ios_test_source.parent().unwrap()).unwrap();
1042 std::fs::write(
1043 &ios_test_source,
1044 "final class BenchRunnerUITests {\n private let expectedBenchmarkFunction = \"test_crate::generated_default\"\n}\n",
1045 )
1046 .unwrap();
1047
1048 embed_bench_spec(&temp_dir, &spec).expect("embed spec");
1049
1050 let android_spec = temp_dir.join("target/mobile-spec/android/bench_spec.json");
1051 let ios_spec = temp_dir.join("target/mobile-spec/ios/bench_spec.json");
1052 assert!(
1053 android_spec.exists(),
1054 "Android Gradle templates read this first-run spec path"
1055 );
1056 assert!(
1057 ios_spec.exists(),
1058 "iOS project templates read this first-run spec path"
1059 );
1060
1061 let contents = std::fs::read_to_string(android_spec).unwrap();
1062 assert!(contents.contains("test_crate::first_run"));
1063 assert!(contents.contains("android_benchmark_timeout_secs"));
1064 assert!(contents.contains("android_heartbeat_interval_secs"));
1065 let json: serde_json::Value = serde_json::from_str(&contents).unwrap();
1066 assert_eq!(json["android_benchmark_timeout_secs"], 30);
1067 assert_eq!(json["android_heartbeat_interval_secs"], 5);
1068 let ios_test_contents = std::fs::read_to_string(ios_test_source).unwrap();
1069 assert!(
1070 ios_test_contents
1071 .contains("private let expectedBenchmarkFunction = \"test_crate::first_run\"")
1072 );
1073 assert!(!ios_test_contents.contains("test_crate::generated_default"));
1074
1075 std::fs::remove_dir_all(&temp_dir).unwrap();
1076 }
1077
1078 #[test]
1079 fn embed_bench_spec_fails_when_generated_ios_ui_test_is_missing() {
1080 let temp_dir = std::env::temp_dir().join(format!(
1081 "mobench-test-embed-spec-missing-ui-test-{}",
1082 std::process::id()
1083 ));
1084 let _ = std::fs::remove_dir_all(&temp_dir);
1085 std::fs::create_dir_all(temp_dir.join("ios/BenchRunner")).unwrap();
1086
1087 #[derive(serde::Serialize)]
1088 struct Spec {
1089 function: String,
1090 }
1091
1092 let error = embed_bench_spec(
1093 &temp_dir,
1094 &Spec {
1095 function: "test_crate::requested".to_string(),
1096 },
1097 )
1098 .expect_err("missing generated UI test must fail closed");
1099 assert!(format!("{error}").contains("Generated iOS XCUITest source is missing"));
1100
1101 std::fs::remove_dir_all(&temp_dir).unwrap();
1102 }
1103
1104 #[test]
1105 fn test_days_to_ymd_epoch() {
1106 let (year, month, day) = days_to_ymd(0);
1108 assert_eq!(year, 1970);
1109 assert_eq!(month, 1);
1110 assert_eq!(day, 1);
1111 }
1112
1113 #[test]
1114 fn test_days_to_ymd_known_date() {
1115 let (year, month, day) = days_to_ymd(365);
1119 assert_eq!(year, 1971);
1120 assert_eq!(month, 1);
1121 assert_eq!(day, 1);
1122 }
1123
1124 #[test]
1125 fn test_is_leap_year() {
1126 assert!(!is_leap_year(1970)); assert!(is_leap_year(2000)); assert!(!is_leap_year(1900)); assert!(is_leap_year(2024)); }
1131
1132 #[test]
1133 fn test_bench_meta_serialization() {
1134 let spec = EmbeddedBenchSpec {
1135 function: "my_func".to_string(),
1136 iterations: 50,
1137 warmup: 5,
1138 };
1139
1140 let meta = create_bench_meta(&spec, "ios", "debug");
1141 let json = serde_json::to_string(&meta).expect("serialization should work");
1142
1143 assert!(json.contains("my_func"));
1145 assert!(json.contains("ios"));
1146 assert!(json.contains("debug"));
1147 assert!(json.contains("build_time"));
1148 assert!(json.contains("mobench_version"));
1149 }
1150}