Skip to main content

mobench_sdk/builders/
common.rs

1//! Common utilities shared between Android and iOS builders.
2//!
3//! This module provides helper functions that are used by both [`super::AndroidBuilder`]
4//! and [`super::IosBuilder`] to ensure consistent behavior and error handling.
5//!
6//! ## Features
7//!
8//! - **Workspace-aware target detection** - Correctly handles Cargo workspaces where
9//!   the target directory is at the workspace root
10//! - **Host library resolution** - Finds compiled libraries for UniFFI binding generation
11//! - **Consistent error handling** - All errors include actionable fix suggestions
12//!
13//! ## Error Messages
14//!
15//! All functions in this module provide detailed, actionable error messages that include:
16//! - What went wrong
17//! - Where it happened (paths, commands)
18//! - How to fix it (specific commands or configuration changes)
19
20use 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/// Builders-side adapter for one declared, bounded external tool invocation.
45#[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
139/// Validates that the project root is a valid directory for building.
140///
141/// This function checks that:
142/// - The path exists
143/// - The path is a directory
144/// - The directory contains a Cargo.toml file (or has a crate directory with one)
145///
146/// # Arguments
147/// * `project_root` - The project root directory to validate
148/// * `crate_name` - The name of the crate being built (used to check crate directories)
149///
150/// # Returns
151/// `Ok(())` if validation passes, or a descriptive `BenchError` if it fails.
152pub fn validate_project_root(project_root: &Path, crate_name: &str) -> Result<(), BenchError> {
153    // Check if path exists
154    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    // Check if path is a directory
163    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    // Check for Cargo.toml in project root or standard crate locations
172    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
193/// Detects the actual Cargo target directory using `cargo metadata`.
194///
195/// This correctly handles Cargo workspaces where the target directory
196/// is at the workspace root, not the crate directory.
197///
198/// # Arguments
199/// * `crate_dir` - Path to the crate directory containing Cargo.toml
200///
201/// # Returns
202/// The path to the target directory, or falls back to `crate_dir/target` if detection fails.
203///
204/// # Warnings
205/// Prints a warning to stderr if falling back to the default target directory due to
206/// cargo metadata failures or parsing issues.
207pub 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        // Fall back to crate_dir/target if cargo metadata fails
226        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    // Fall back to crate_dir/target if JSON parsing fails
248    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
258/// Finds the host library path for UniFFI binding generation.
259///
260/// UniFFI requires a host-compiled library to generate bindings. This function
261/// locates that library in the target directory.
262///
263/// # Arguments
264/// * `crate_dir` - Path to the crate directory
265/// * `crate_name` - Name of the crate (used to construct library filename)
266///
267/// # Returns
268/// Path to the host library (e.g., `libfoo.dylib` on macOS, `libfoo.so` on Linux)
269pub 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    // Use cargo metadata to find the actual target directory
291    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
317/// Runs an external command with consistent error handling.
318///
319/// Captures both stdout and stderr on failure and formats them into
320/// an actionable error message.
321///
322/// # Arguments
323/// * `cmd` - The command to execute
324/// * `description` - Human-readable description of what the command does
325///
326/// # Returns
327/// `Ok(())` if the command succeeds, or a `BenchError` with detailed output on failure.
328pub(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
352/// Runs an externally configured command with consistent error handling.
353///
354/// This compatibility entry point intentionally retains the released
355/// `Command::output` behavior. Rust does not expose whether a `Command` used
356/// `env_clear()`, so reconstructing it as a `ToolCommand` could accidentally
357/// reintroduce ambient credentials. New internal builder call sites use the
358/// bounded supervisor directly.
359pub 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
383/// Reads the package name from a Cargo.toml file.
384///
385/// This function parses the `[package]` section of a Cargo.toml and extracts
386/// the `name` field. It uses simple string parsing to avoid adding toml
387/// dependencies.
388///
389/// # Arguments
390/// * `cargo_toml_path` - Path to the Cargo.toml file
391///
392/// # Returns
393/// `Some(name)` if the package name is found, `None` otherwise.
394///
395/// # Example
396/// ```ignore
397/// let name = read_package_name(Path::new("/path/to/Cargo.toml"));
398/// if let Some(name) = name {
399///     println!("Package name: {}", name);
400/// }
401/// ```
402pub fn read_package_name(cargo_toml_path: &Path) -> Option<String> {
403    let content = std::fs::read_to_string(cargo_toml_path).ok()?;
404
405    // Find [package] section
406    let package_start = content.find("[package]")?;
407    let package_section = &content[package_start..];
408
409    // Find the end of the package section (next section or end of file)
410    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    // Find name = "..." or name = '...'
417    for line in package_section.lines() {
418        let trimmed = line.trim();
419        if trimmed.starts_with("name") {
420            // Parse: name = "value" or name = 'value'
421            if let Some(eq_pos) = trimmed.find('=') {
422                let value_part = trimmed[eq_pos + 1..].trim();
423                // Extract string value (handle both " and ')
424                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
441/// Embeds a bench spec JSON file into the Android assets and iOS bundle resources.
442///
443/// This function writes a `bench_spec.json` file to the appropriate location for
444/// both Android (assets directory) and iOS (bundle resources) so the mobile app
445/// can read the benchmark configuration at runtime.
446///
447/// # Arguments
448/// * `output_dir` - The mobench output directory (e.g., `target/mobench`)
449/// * `spec` - The benchmark specification as a JSON-serializable struct
450///
451/// # Example
452/// ```ignore
453/// use mobench_sdk::builders::common::embed_bench_spec;
454/// use mobench_sdk::BenchSpec;
455///
456/// let spec = BenchSpec {
457///     name: "my_crate::my_benchmark".to_string(),
458///     iterations: 100,
459///     warmup: 10,
460/// };
461///
462/// embed_bench_spec(Path::new("target/mobench"), &spec)?;
463/// ```
464pub 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    // Generated Android/iOS projects include these output-local resources even
474    // before their app scaffolds exist, which keeps clean first runs deterministic.
475    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    // Android: Write to assets directory
498    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    // iOS: Write to Resources directory in the Xcode project
518    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/// Represents a benchmark specification for embedding.
610///
611/// This is a simple struct that can be serialized to JSON and embedded
612/// in mobile app bundles.
613#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
614pub struct EmbeddedBenchSpec {
615    /// The benchmark function name (e.g., "my_crate::my_benchmark")
616    pub function: String,
617    /// Number of benchmark iterations
618    pub iterations: u32,
619    /// Number of warmup iterations
620    pub warmup: u32,
621}
622
623/// Build metadata for artifact correlation and traceability.
624///
625/// This struct captures metadata about the build environment to enable
626/// reproducibility and debugging of benchmark results.
627#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
628pub struct BenchMeta {
629    /// Benchmark specification that was used
630    pub spec: EmbeddedBenchSpec,
631    /// Git commit hash (if in a git repository)
632    #[serde(skip_serializing_if = "Option::is_none")]
633    pub commit_hash: Option<String>,
634    /// Git branch name (if available)
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub branch: Option<String>,
637    /// Whether the git working directory was dirty
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub dirty: Option<bool>,
640    /// Build timestamp in RFC3339 format
641    pub build_time: String,
642    /// Build timestamp as Unix epoch seconds
643    pub build_time_unix: u64,
644    /// Target platform ("android" or "ios")
645    pub target: String,
646    /// Build profile ("debug" or "release")
647    pub profile: String,
648    /// mobench version
649    pub mobench_version: String,
650    /// Rust version used for the build
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub rust_version: Option<String>,
653    /// Host OS (e.g., "macos", "linux")
654    pub host_os: String,
655}
656
657/// Gets the current git commit hash (short form).
658pub 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
674/// Gets the current git branch name.
675pub 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
691/// Checks if the git working directory has uncommitted changes.
692pub 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
707/// Gets the Rust version.
708pub 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
722/// Creates a BenchMeta instance with current build information.
723pub 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    // Format as RFC3339
731    let build_time = {
732        let secs = now.as_secs();
733        // Simple UTC timestamp formatting
734        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        // Calculate year, month, day from days since epoch (1970-01-01)
741        // Simplified calculation - good enough for build metadata
742        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
765/// Convert days since epoch to (year, month, day).
766/// Simplified Gregorian calendar calculation.
767fn days_to_ymd(days: u64) -> (i32, u32, u32) {
768    let mut remaining_days = days as i64;
769    let mut year = 1970i32;
770
771    // Advance years
772    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    // Days in each month (non-leap year)
782    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
804/// Embeds build metadata (bench_meta.json) alongside bench_spec.json in mobile app bundles.
805///
806/// This function creates a `bench_meta.json` file that contains:
807/// - The benchmark specification
808/// - Git commit hash and branch (if available)
809/// - Build timestamp
810/// - Target platform and profile
811/// - mobench and Rust versions
812///
813/// # Arguments
814/// * `output_dir` - The mobench output directory (e.g., `target/mobench`)
815/// * `spec` - The benchmark specification
816/// * `target` - Target platform ("android" or "ios")
817/// * `profile` - Build profile ("debug" or "release")
818pub 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    // Android: Write to assets directory
829    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    // iOS: Write to Resources directory in the Xcode project
849    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        // For a non-existent directory, should fall back gracefully
878        let result = get_cargo_target_dir(Path::new("/nonexistent/path"));
879        // Should either error or return fallback path
880        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        // Build time should be in RFC3339 format (roughly YYYY-MM-DDTHH:MM:SSZ)
1011        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        // Day 0 should be January 1, 1970
1107        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        // January 21, 2026 is approximately 20,474 days since epoch
1116        // (2026 - 1970 = 56 years, with leap years)
1117        // Let's test a simpler case: 365 days = January 1, 1971
1118        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)); // Not divisible by 4
1127        assert!(is_leap_year(2000)); // Divisible by 400
1128        assert!(!is_leap_year(1900)); // Divisible by 100 but not 400
1129        assert!(is_leap_year(2024)); // Divisible by 4, not by 100
1130    }
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        // Verify it contains expected fields
1144        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}