1use std::env;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use serde::Deserialize;
25
26use crate::types::BenchError;
27
28#[derive(Deserialize)]
29struct CargoMetadata {
30 target_directory: String,
31}
32
33pub fn validate_project_root(project_root: &Path, crate_name: &str) -> Result<(), BenchError> {
47 if !project_root.exists() {
49 return Err(BenchError::Build(format!(
50 "Project root does not exist: {}\n\n\
51 Ensure you are running from the correct directory or specify --project-root.",
52 project_root.display()
53 )));
54 }
55
56 if !project_root.is_dir() {
58 return Err(BenchError::Build(format!(
59 "Project root is not a directory: {}\n\n\
60 Expected a directory containing your Rust project.",
61 project_root.display()
62 )));
63 }
64
65 let root_cargo = project_root.join("Cargo.toml");
67 let bench_mobile_cargo = project_root.join("bench-mobile/Cargo.toml");
68 let crates_cargo = project_root.join(format!("crates/{}/Cargo.toml", crate_name));
69
70 if !root_cargo.exists() && !bench_mobile_cargo.exists() && !crates_cargo.exists() {
71 return Err(BenchError::Build(format!(
72 "No Cargo.toml found in project root or expected crate locations.\n\n\
73 Searched:\n\
74 - {}\n\
75 - {}\n\
76 - {}\n\n\
77 Ensure you are in a Rust project directory or use --crate-path to specify the crate location.",
78 root_cargo.display(),
79 bench_mobile_cargo.display(),
80 crates_cargo.display()
81 )));
82 }
83
84 Ok(())
85}
86
87pub fn get_cargo_target_dir(crate_dir: &Path) -> Result<PathBuf, BenchError> {
102 let output = Command::new("cargo")
103 .args(["metadata", "--format-version", "1", "--no-deps"])
104 .current_dir(crate_dir)
105 .output()
106 .map_err(|e| {
107 BenchError::Build(format!(
108 "Failed to run cargo metadata.\n\n\
109 Working directory: {}\n\
110 Error: {}\n\n\
111 Ensure cargo is installed and on PATH.",
112 crate_dir.display(),
113 e
114 ))
115 })?;
116
117 if !output.status.success() {
118 let fallback = crate_dir.join("target");
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 eprintln!(
122 "Warning: cargo metadata failed (exit {}), falling back to {}.\n\
123 Stderr: {}\n\
124 This may cause build issues if you are in a Cargo workspace.",
125 output.status,
126 fallback.display(),
127 stderr.lines().take(3).collect::<Vec<_>>().join("\n")
128 );
129 return Ok(fallback);
130 }
131
132 match serde_json::from_slice::<CargoMetadata>(&output.stdout) {
133 Ok(metadata) => return Ok(PathBuf::from(metadata.target_directory)),
134 Err(err) => eprintln!(
135 "Warning: Failed to parse cargo metadata JSON ({}). Falling back to crate-local target dir.",
136 err
137 ),
138 }
139
140 let fallback = crate_dir.join("target");
142 eprintln!(
143 "Warning: Failed to parse target_directory from cargo metadata output, \
144 falling back to {}.\n\
145 This may cause build issues if you are in a Cargo workspace.",
146 fallback.display()
147 );
148 Ok(fallback)
149}
150
151pub fn host_lib_path(crate_dir: &Path, crate_name: &str) -> Result<PathBuf, BenchError> {
163 let lib_prefix = if cfg!(target_os = "windows") {
164 ""
165 } else {
166 "lib"
167 };
168 let lib_ext = match env::consts::OS {
169 "macos" => "dylib",
170 "linux" => "so",
171 other => {
172 return Err(BenchError::Build(format!(
173 "Unsupported host OS for binding generation: {}\n\n\
174 Supported platforms:\n\
175 - macOS (generates .dylib)\n\
176 - Linux (generates .so)\n\n\
177 Windows is not currently supported for binding generation.",
178 other
179 )));
180 }
181 };
182
183 let target_dir = get_cargo_target_dir(crate_dir)?;
185
186 let lib_name = format!("{}{}.{}", lib_prefix, crate_name.replace('-', "_"), lib_ext);
187 let path = target_dir.join("debug").join(&lib_name);
188
189 if !path.exists() {
190 return Err(BenchError::Build(format!(
191 "Host library for UniFFI not found.\n\n\
192 Expected: {}\n\
193 Target directory: {}\n\n\
194 To fix this:\n\
195 1. Build the host library first:\n\
196 cargo build -p {}\n\n\
197 2. Ensure your crate produces a cdylib:\n\
198 [lib]\n\
199 crate-type = [\"cdylib\"]\n\n\
200 3. Check that the library name matches: {}",
201 path.display(),
202 target_dir.display(),
203 crate_name,
204 lib_name
205 )));
206 }
207 Ok(path)
208}
209
210pub fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
222 let output = cmd.output().map_err(|e| {
223 BenchError::Build(format!(
224 "Failed to start {}.\n\n\
225 Error: {}\n\n\
226 Ensure the tool is installed and available on PATH.",
227 description, e
228 ))
229 })?;
230
231 if !output.status.success() {
232 let stdout = String::from_utf8_lossy(&output.stdout);
233 let stderr = String::from_utf8_lossy(&output.stderr);
234 return Err(BenchError::Build(format!(
235 "{} failed.\n\n\
236 Exit status: {}\n\n\
237 Stdout:\n{}\n\n\
238 Stderr:\n{}",
239 description, output.status, stdout, stderr
240 )));
241 }
242 Ok(())
243}
244
245pub fn read_package_name(cargo_toml_path: &Path) -> Option<String> {
265 let content = std::fs::read_to_string(cargo_toml_path).ok()?;
266
267 let package_start = content.find("[package]")?;
269 let package_section = &content[package_start..];
270
271 let section_end = package_section[1..]
273 .find("\n[")
274 .map(|i| i + 1)
275 .unwrap_or(package_section.len());
276 let package_section = &package_section[..section_end];
277
278 for line in package_section.lines() {
280 let trimmed = line.trim();
281 if trimmed.starts_with("name") {
282 if let Some(eq_pos) = trimmed.find('=') {
284 let value_part = trimmed[eq_pos + 1..].trim();
285 let (quote_char, start) = if value_part.starts_with('"') {
287 ('"', 1)
288 } else if value_part.starts_with('\'') {
289 ('\'', 1)
290 } else {
291 continue;
292 };
293 if let Some(end) = value_part[start..].find(quote_char) {
294 return Some(value_part[start..start + end].to_string());
295 }
296 }
297 }
298 }
299
300 None
301}
302
303pub fn embed_bench_spec<S: serde::Serialize>(
327 output_dir: &Path,
328 spec: &S,
329) -> Result<(), BenchError> {
330 let spec_json = serde_json::to_string_pretty(spec)
331 .map_err(|e| BenchError::Build(format!("Failed to serialize bench spec: {}", e)))?;
332
333 for spec_path in [
336 output_dir.join("target/mobile-spec/android/bench_spec.json"),
337 output_dir.join("target/mobile-spec/ios/bench_spec.json"),
338 ] {
339 if let Some(parent) = spec_path.parent() {
340 std::fs::create_dir_all(parent).map_err(|e| {
341 BenchError::Build(format!(
342 "Failed to create bench spec directory at {}: {}",
343 parent.display(),
344 e
345 ))
346 })?;
347 }
348 std::fs::write(&spec_path, &spec_json).map_err(|e| {
349 BenchError::Build(format!(
350 "Failed to write bench spec to {}: {}",
351 spec_path.display(),
352 e
353 ))
354 })?;
355 }
356
357 let android_assets_dir = output_dir.join("android/app/src/main/assets");
359 if output_dir.join("android").exists() {
360 std::fs::create_dir_all(&android_assets_dir).map_err(|e| {
361 BenchError::Build(format!(
362 "Failed to create Android assets directory at {}: {}",
363 android_assets_dir.display(),
364 e
365 ))
366 })?;
367 let android_spec_path = android_assets_dir.join("bench_spec.json");
368 std::fs::write(&android_spec_path, &spec_json).map_err(|e| {
369 BenchError::Build(format!(
370 "Failed to write Android bench spec to {}: {}",
371 android_spec_path.display(),
372 e
373 ))
374 })?;
375 }
376
377 let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources");
379 if output_dir.join("ios/BenchRunner").exists() {
380 std::fs::create_dir_all(&ios_resources_dir).map_err(|e| {
381 BenchError::Build(format!(
382 "Failed to create iOS Resources directory at {}: {}",
383 ios_resources_dir.display(),
384 e
385 ))
386 })?;
387 let ios_spec_path = ios_resources_dir.join("bench_spec.json");
388 std::fs::write(&ios_spec_path, &spec_json).map_err(|e| {
389 BenchError::Build(format!(
390 "Failed to write iOS bench spec to {}: {}",
391 ios_spec_path.display(),
392 e
393 ))
394 })?;
395 }
396
397 Ok(())
398}
399
400#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
405pub struct EmbeddedBenchSpec {
406 pub function: String,
408 pub iterations: u32,
410 pub warmup: u32,
412}
413
414#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
419pub struct BenchMeta {
420 pub spec: EmbeddedBenchSpec,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub commit_hash: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
427 pub branch: Option<String>,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub dirty: Option<bool>,
431 pub build_time: String,
433 pub build_time_unix: u64,
435 pub target: String,
437 pub profile: String,
439 pub mobench_version: String,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 pub rust_version: Option<String>,
444 pub host_os: String,
446}
447
448pub fn get_git_commit() -> Option<String> {
450 let output = Command::new("git")
451 .args(["rev-parse", "--short", "HEAD"])
452 .output()
453 .ok()?;
454
455 if output.status.success() {
456 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
457 if !hash.is_empty() {
458 return Some(hash);
459 }
460 }
461 None
462}
463
464pub fn get_git_branch() -> Option<String> {
466 let output = Command::new("git")
467 .args(["rev-parse", "--abbrev-ref", "HEAD"])
468 .output()
469 .ok()?;
470
471 if output.status.success() {
472 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
473 if !branch.is_empty() && branch != "HEAD" {
474 return Some(branch);
475 }
476 }
477 None
478}
479
480pub fn is_git_dirty() -> Option<bool> {
482 let output = Command::new("git")
483 .args(["status", "--porcelain"])
484 .output()
485 .ok()?;
486
487 if output.status.success() {
488 let status = String::from_utf8_lossy(&output.stdout);
489 Some(!status.trim().is_empty())
490 } else {
491 None
492 }
493}
494
495pub fn get_rust_version() -> Option<String> {
497 let output = Command::new("rustc").args(["--version"]).output().ok()?;
498
499 if output.status.success() {
500 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
501 if !version.is_empty() {
502 return Some(version);
503 }
504 }
505 None
506}
507
508pub fn create_bench_meta(spec: &EmbeddedBenchSpec, target: &str, profile: &str) -> BenchMeta {
510 use std::time::{SystemTime, UNIX_EPOCH};
511
512 let now = SystemTime::now()
513 .duration_since(UNIX_EPOCH)
514 .unwrap_or_default();
515
516 let build_time = {
518 let secs = now.as_secs();
519 let days_since_epoch = secs / 86400;
521 let remaining_secs = secs % 86400;
522 let hours = remaining_secs / 3600;
523 let minutes = (remaining_secs % 3600) / 60;
524 let seconds = remaining_secs % 60;
525
526 let (year, month, day) = days_to_ymd(days_since_epoch);
529
530 format!(
531 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
532 year, month, day, hours, minutes, seconds
533 )
534 };
535
536 BenchMeta {
537 spec: spec.clone(),
538 commit_hash: get_git_commit(),
539 branch: get_git_branch(),
540 dirty: is_git_dirty(),
541 build_time,
542 build_time_unix: now.as_secs(),
543 target: target.to_string(),
544 profile: profile.to_string(),
545 mobench_version: env!("CARGO_PKG_VERSION").to_string(),
546 rust_version: get_rust_version(),
547 host_os: env::consts::OS.to_string(),
548 }
549}
550
551fn days_to_ymd(days: u64) -> (i32, u32, u32) {
554 let mut remaining_days = days as i64;
555 let mut year = 1970i32;
556
557 loop {
559 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
560 if remaining_days < days_in_year {
561 break;
562 }
563 remaining_days -= days_in_year;
564 year += 1;
565 }
566
567 let days_in_months: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
569
570 let mut month = 1u32;
571 for (i, &days_in_month) in days_in_months.iter().enumerate() {
572 let mut dim = days_in_month;
573 if i == 1 && is_leap_year(year) {
574 dim = 29;
575 }
576 if remaining_days < dim {
577 break;
578 }
579 remaining_days -= dim;
580 month += 1;
581 }
582
583 (year, month, remaining_days as u32 + 1)
584}
585
586fn is_leap_year(year: i32) -> bool {
587 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
588}
589
590pub fn embed_bench_meta(
605 output_dir: &Path,
606 spec: &EmbeddedBenchSpec,
607 target: &str,
608 profile: &str,
609) -> Result<(), BenchError> {
610 let meta = create_bench_meta(spec, target, profile);
611 let meta_json = serde_json::to_string_pretty(&meta)
612 .map_err(|e| BenchError::Build(format!("Failed to serialize bench meta: {}", e)))?;
613
614 let android_assets_dir = output_dir.join("android/app/src/main/assets");
616 if output_dir.join("android").exists() {
617 std::fs::create_dir_all(&android_assets_dir).map_err(|e| {
618 BenchError::Build(format!(
619 "Failed to create Android assets directory at {}: {}",
620 android_assets_dir.display(),
621 e
622 ))
623 })?;
624 let android_meta_path = android_assets_dir.join("bench_meta.json");
625 std::fs::write(&android_meta_path, &meta_json).map_err(|e| {
626 BenchError::Build(format!(
627 "Failed to write Android bench meta to {}: {}",
628 android_meta_path.display(),
629 e
630 ))
631 })?;
632 }
633
634 let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources");
636 if output_dir.join("ios/BenchRunner").exists() {
637 std::fs::create_dir_all(&ios_resources_dir).map_err(|e| {
638 BenchError::Build(format!(
639 "Failed to create iOS Resources directory at {}: {}",
640 ios_resources_dir.display(),
641 e
642 ))
643 })?;
644 let ios_meta_path = ios_resources_dir.join("bench_meta.json");
645 std::fs::write(&ios_meta_path, &meta_json).map_err(|e| {
646 BenchError::Build(format!(
647 "Failed to write iOS bench meta to {}: {}",
648 ios_meta_path.display(),
649 e
650 ))
651 })?;
652 }
653
654 Ok(())
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 #[test]
662 fn test_get_cargo_target_dir_fallback() {
663 let result = get_cargo_target_dir(Path::new("/nonexistent/path"));
665 assert!(result.is_ok() || result.is_err());
667 }
668
669 #[test]
670 fn test_host_lib_path_not_found() {
671 let result = host_lib_path(Path::new("/tmp"), "nonexistent-crate");
672 assert!(result.is_err());
673 let err = result.unwrap_err();
674 let msg = format!("{}", err);
675 assert!(msg.contains("Host library for UniFFI not found"));
676 assert!(msg.contains("cargo build"));
677 }
678
679 #[test]
680 fn test_run_command_not_found() {
681 let cmd = Command::new("nonexistent-command-12345");
682 let result = run_command(cmd, "test command");
683 assert!(result.is_err());
684 let err = result.unwrap_err();
685 let msg = format!("{}", err);
686 assert!(msg.contains("Failed to start"));
687 }
688
689 #[test]
690 fn test_read_package_name_standard() {
691 let temp_dir = std::env::temp_dir().join("mobench-test-read-package");
692 let _ = std::fs::remove_dir_all(&temp_dir);
693 std::fs::create_dir_all(&temp_dir).unwrap();
694
695 let cargo_toml = temp_dir.join("Cargo.toml");
696 std::fs::write(
697 &cargo_toml,
698 r#"[package]
699name = "my-awesome-crate"
700version = "0.1.0"
701edition = "2021"
702
703[dependencies]
704"#,
705 )
706 .unwrap();
707
708 let result = read_package_name(&cargo_toml);
709 assert_eq!(result, Some("my-awesome-crate".to_string()));
710
711 std::fs::remove_dir_all(&temp_dir).unwrap();
712 }
713
714 #[test]
715 fn test_read_package_name_with_single_quotes() {
716 let temp_dir = std::env::temp_dir().join("mobench-test-read-package-sq");
717 let _ = std::fs::remove_dir_all(&temp_dir);
718 std::fs::create_dir_all(&temp_dir).unwrap();
719
720 let cargo_toml = temp_dir.join("Cargo.toml");
721 std::fs::write(
722 &cargo_toml,
723 r#"[package]
724name = 'single-quoted-crate'
725version = "0.1.0"
726"#,
727 )
728 .unwrap();
729
730 let result = read_package_name(&cargo_toml);
731 assert_eq!(result, Some("single-quoted-crate".to_string()));
732
733 std::fs::remove_dir_all(&temp_dir).unwrap();
734 }
735
736 #[test]
737 fn test_read_package_name_not_found() {
738 let result = read_package_name(Path::new("/nonexistent/Cargo.toml"));
739 assert_eq!(result, None);
740 }
741
742 #[test]
743 fn test_read_package_name_no_package_section() {
744 let temp_dir = std::env::temp_dir().join("mobench-test-read-package-no-pkg");
745 let _ = std::fs::remove_dir_all(&temp_dir);
746 std::fs::create_dir_all(&temp_dir).unwrap();
747
748 let cargo_toml = temp_dir.join("Cargo.toml");
749 std::fs::write(
750 &cargo_toml,
751 r#"[workspace]
752members = ["crates/*"]
753"#,
754 )
755 .unwrap();
756
757 let result = read_package_name(&cargo_toml);
758 assert_eq!(result, None);
759
760 std::fs::remove_dir_all(&temp_dir).unwrap();
761 }
762
763 #[test]
764 fn test_create_bench_meta() {
765 let spec = EmbeddedBenchSpec {
766 function: "test_crate::my_benchmark".to_string(),
767 iterations: 100,
768 warmup: 10,
769 };
770
771 let meta = create_bench_meta(&spec, "android", "release");
772
773 assert_eq!(meta.spec.function, "test_crate::my_benchmark");
774 assert_eq!(meta.spec.iterations, 100);
775 assert_eq!(meta.spec.warmup, 10);
776 assert_eq!(meta.target, "android");
777 assert_eq!(meta.profile, "release");
778 assert!(!meta.mobench_version.is_empty());
779 assert!(!meta.host_os.is_empty());
780 assert!(!meta.build_time.is_empty());
781 assert!(meta.build_time_unix > 0);
782 assert!(meta.build_time.contains('T'));
784 assert!(meta.build_time.ends_with('Z'));
785 }
786
787 #[test]
788 fn embed_bench_spec_writes_first_run_mobile_spec_locations() {
789 let temp_dir =
790 std::env::temp_dir().join(format!("mobench-test-embed-spec-{}", std::process::id()));
791 let _ = std::fs::remove_dir_all(&temp_dir);
792 std::fs::create_dir_all(&temp_dir).unwrap();
793
794 #[derive(serde::Serialize)]
795 struct AndroidSpec {
796 function: String,
797 iterations: u32,
798 warmup: u32,
799 android_benchmark_timeout_secs: Option<u64>,
800 android_heartbeat_interval_secs: Option<u64>,
801 }
802
803 let spec = AndroidSpec {
804 function: "test_crate::first_run".to_string(),
805 iterations: 7,
806 warmup: 1,
807 android_benchmark_timeout_secs: Some(30),
808 android_heartbeat_interval_secs: Some(5),
809 };
810
811 embed_bench_spec(&temp_dir, &spec).expect("embed spec");
812
813 let android_spec = temp_dir.join("target/mobile-spec/android/bench_spec.json");
814 let ios_spec = temp_dir.join("target/mobile-spec/ios/bench_spec.json");
815 assert!(
816 android_spec.exists(),
817 "Android Gradle templates read this first-run spec path"
818 );
819 assert!(
820 ios_spec.exists(),
821 "iOS project templates read this first-run spec path"
822 );
823
824 let contents = std::fs::read_to_string(android_spec).unwrap();
825 assert!(contents.contains("test_crate::first_run"));
826 assert!(contents.contains("android_benchmark_timeout_secs"));
827 assert!(contents.contains("android_heartbeat_interval_secs"));
828 let json: serde_json::Value = serde_json::from_str(&contents).unwrap();
829 assert_eq!(json["android_benchmark_timeout_secs"], 30);
830 assert_eq!(json["android_heartbeat_interval_secs"], 5);
831
832 std::fs::remove_dir_all(&temp_dir).unwrap();
833 }
834
835 #[test]
836 fn test_days_to_ymd_epoch() {
837 let (year, month, day) = days_to_ymd(0);
839 assert_eq!(year, 1970);
840 assert_eq!(month, 1);
841 assert_eq!(day, 1);
842 }
843
844 #[test]
845 fn test_days_to_ymd_known_date() {
846 let (year, month, day) = days_to_ymd(365);
850 assert_eq!(year, 1971);
851 assert_eq!(month, 1);
852 assert_eq!(day, 1);
853 }
854
855 #[test]
856 fn test_is_leap_year() {
857 assert!(!is_leap_year(1970)); assert!(is_leap_year(2000)); assert!(!is_leap_year(1900)); assert!(is_leap_year(2024)); }
862
863 #[test]
864 fn test_bench_meta_serialization() {
865 let spec = EmbeddedBenchSpec {
866 function: "my_func".to_string(),
867 iterations: 50,
868 warmup: 5,
869 };
870
871 let meta = create_bench_meta(&spec, "ios", "debug");
872 let json = serde_json::to_string(&meta).expect("serialization should work");
873
874 assert!(json.contains("my_func"));
876 assert!(json.contains("ios"));
877 assert!(json.contains("debug"));
878 assert!(json.contains("build_time"));
879 assert!(json.contains("mobench_version"));
880 }
881}