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::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
33/// Validates that the project root is a valid directory for building.
34///
35/// This function checks that:
36/// - The path exists
37/// - The path is a directory
38/// - The directory contains a Cargo.toml file (or has a crate directory with one)
39///
40/// # Arguments
41/// * `project_root` - The project root directory to validate
42/// * `crate_name` - The name of the crate being built (used to check crate directories)
43///
44/// # Returns
45/// `Ok(())` if validation passes, or a descriptive `BenchError` if it fails.
46pub fn validate_project_root(project_root: &Path, crate_name: &str) -> Result<(), BenchError> {
47    // Check if path exists
48    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    // Check if path is a directory
57    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    // Check for Cargo.toml in project root or standard crate locations
66    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
87/// Detects the actual Cargo target directory using `cargo metadata`.
88///
89/// This correctly handles Cargo workspaces where the target directory
90/// is at the workspace root, not the crate directory.
91///
92/// # Arguments
93/// * `crate_dir` - Path to the crate directory containing Cargo.toml
94///
95/// # Returns
96/// The path to the target directory, or falls back to `crate_dir/target` if detection fails.
97///
98/// # Warnings
99/// Prints a warning to stderr if falling back to the default target directory due to
100/// cargo metadata failures or parsing issues.
101pub 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        // Fall back to crate_dir/target if cargo metadata fails
119        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    // Fall back to crate_dir/target if JSON parsing fails
141    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
151/// Finds the host library path for UniFFI binding generation.
152///
153/// UniFFI requires a host-compiled library to generate bindings. This function
154/// locates that library in the target directory.
155///
156/// # Arguments
157/// * `crate_dir` - Path to the crate directory
158/// * `crate_name` - Name of the crate (used to construct library filename)
159///
160/// # Returns
161/// Path to the host library (e.g., `libfoo.dylib` on macOS, `libfoo.so` on Linux)
162pub 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    // Use cargo metadata to find the actual target directory
184    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
210/// Runs an external command with consistent error handling.
211///
212/// Captures both stdout and stderr on failure and formats them into
213/// an actionable error message.
214///
215/// # Arguments
216/// * `cmd` - The command to execute
217/// * `description` - Human-readable description of what the command does
218///
219/// # Returns
220/// `Ok(())` if the command succeeds, or a `BenchError` with detailed output on failure.
221pub 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
245/// Reads the package name from a Cargo.toml file.
246///
247/// This function parses the `[package]` section of a Cargo.toml and extracts
248/// the `name` field. It uses simple string parsing to avoid adding toml
249/// dependencies.
250///
251/// # Arguments
252/// * `cargo_toml_path` - Path to the Cargo.toml file
253///
254/// # Returns
255/// `Some(name)` if the package name is found, `None` otherwise.
256///
257/// # Example
258/// ```ignore
259/// let name = read_package_name(Path::new("/path/to/Cargo.toml"));
260/// if let Some(name) = name {
261///     println!("Package name: {}", name);
262/// }
263/// ```
264pub fn read_package_name(cargo_toml_path: &Path) -> Option<String> {
265    let content = std::fs::read_to_string(cargo_toml_path).ok()?;
266
267    // Find [package] section
268    let package_start = content.find("[package]")?;
269    let package_section = &content[package_start..];
270
271    // Find the end of the package section (next section or end of file)
272    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    // Find name = "..." or name = '...'
279    for line in package_section.lines() {
280        let trimmed = line.trim();
281        if trimmed.starts_with("name") {
282            // Parse: name = "value" or name = 'value'
283            if let Some(eq_pos) = trimmed.find('=') {
284                let value_part = trimmed[eq_pos + 1..].trim();
285                // Extract string value (handle both " and ')
286                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
303/// Embeds a bench spec JSON file into the Android assets and iOS bundle resources.
304///
305/// This function writes a `bench_spec.json` file to the appropriate location for
306/// both Android (assets directory) and iOS (bundle resources) so the mobile app
307/// can read the benchmark configuration at runtime.
308///
309/// # Arguments
310/// * `output_dir` - The mobench output directory (e.g., `target/mobench`)
311/// * `spec` - The benchmark specification as a JSON-serializable struct
312///
313/// # Example
314/// ```ignore
315/// use mobench_sdk::builders::common::embed_bench_spec;
316/// use mobench_sdk::BenchSpec;
317///
318/// let spec = BenchSpec {
319///     name: "my_crate::my_benchmark".to_string(),
320///     iterations: 100,
321///     warmup: 10,
322/// };
323///
324/// embed_bench_spec(Path::new("target/mobench"), &spec)?;
325/// ```
326pub 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    // Android: Write to assets directory
334    let android_assets_dir = output_dir.join("android/app/src/main/assets");
335    if output_dir.join("android").exists() {
336        std::fs::create_dir_all(&android_assets_dir).map_err(|e| {
337            BenchError::Build(format!(
338                "Failed to create Android assets directory at {}: {}",
339                android_assets_dir.display(),
340                e
341            ))
342        })?;
343        let android_spec_path = android_assets_dir.join("bench_spec.json");
344        std::fs::write(&android_spec_path, &spec_json).map_err(|e| {
345            BenchError::Build(format!(
346                "Failed to write Android bench spec to {}: {}",
347                android_spec_path.display(),
348                e
349            ))
350        })?;
351    }
352
353    // iOS: Write to Resources directory in the Xcode project
354    let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources");
355    if output_dir.join("ios/BenchRunner").exists() {
356        std::fs::create_dir_all(&ios_resources_dir).map_err(|e| {
357            BenchError::Build(format!(
358                "Failed to create iOS Resources directory at {}: {}",
359                ios_resources_dir.display(),
360                e
361            ))
362        })?;
363        let ios_spec_path = ios_resources_dir.join("bench_spec.json");
364        std::fs::write(&ios_spec_path, &spec_json).map_err(|e| {
365            BenchError::Build(format!(
366                "Failed to write iOS bench spec to {}: {}",
367                ios_spec_path.display(),
368                e
369            ))
370        })?;
371    }
372
373    Ok(())
374}
375
376/// Represents a benchmark specification for embedding.
377///
378/// This is a simple struct that can be serialized to JSON and embedded
379/// in mobile app bundles.
380#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
381pub struct EmbeddedBenchSpec {
382    /// The benchmark function name (e.g., "my_crate::my_benchmark")
383    pub function: String,
384    /// Number of benchmark iterations
385    pub iterations: u32,
386    /// Number of warmup iterations
387    pub warmup: u32,
388}
389
390/// Build metadata for artifact correlation and traceability.
391///
392/// This struct captures metadata about the build environment to enable
393/// reproducibility and debugging of benchmark results.
394#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
395pub struct BenchMeta {
396    /// Benchmark specification that was used
397    pub spec: EmbeddedBenchSpec,
398    /// Git commit hash (if in a git repository)
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub commit_hash: Option<String>,
401    /// Git branch name (if available)
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub branch: Option<String>,
404    /// Whether the git working directory was dirty
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub dirty: Option<bool>,
407    /// Build timestamp in RFC3339 format
408    pub build_time: String,
409    /// Build timestamp as Unix epoch seconds
410    pub build_time_unix: u64,
411    /// Target platform ("android" or "ios")
412    pub target: String,
413    /// Build profile ("debug" or "release")
414    pub profile: String,
415    /// mobench version
416    pub mobench_version: String,
417    /// Rust version used for the build
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub rust_version: Option<String>,
420    /// Host OS (e.g., "macos", "linux")
421    pub host_os: String,
422}
423
424/// Gets the current git commit hash (short form).
425pub fn get_git_commit() -> Option<String> {
426    let output = Command::new("git")
427        .args(["rev-parse", "--short", "HEAD"])
428        .output()
429        .ok()?;
430
431    if output.status.success() {
432        let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
433        if !hash.is_empty() {
434            return Some(hash);
435        }
436    }
437    None
438}
439
440/// Gets the current git branch name.
441pub fn get_git_branch() -> Option<String> {
442    let output = Command::new("git")
443        .args(["rev-parse", "--abbrev-ref", "HEAD"])
444        .output()
445        .ok()?;
446
447    if output.status.success() {
448        let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
449        if !branch.is_empty() && branch != "HEAD" {
450            return Some(branch);
451        }
452    }
453    None
454}
455
456/// Checks if the git working directory has uncommitted changes.
457pub fn is_git_dirty() -> Option<bool> {
458    let output = Command::new("git")
459        .args(["status", "--porcelain"])
460        .output()
461        .ok()?;
462
463    if output.status.success() {
464        let status = String::from_utf8_lossy(&output.stdout);
465        Some(!status.trim().is_empty())
466    } else {
467        None
468    }
469}
470
471/// Gets the Rust version.
472pub fn get_rust_version() -> Option<String> {
473    let output = Command::new("rustc").args(["--version"]).output().ok()?;
474
475    if output.status.success() {
476        let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
477        if !version.is_empty() {
478            return Some(version);
479        }
480    }
481    None
482}
483
484/// Creates a BenchMeta instance with current build information.
485pub fn create_bench_meta(spec: &EmbeddedBenchSpec, target: &str, profile: &str) -> BenchMeta {
486    use std::time::{SystemTime, UNIX_EPOCH};
487
488    let now = SystemTime::now()
489        .duration_since(UNIX_EPOCH)
490        .unwrap_or_default();
491
492    // Format as RFC3339
493    let build_time = {
494        let secs = now.as_secs();
495        // Simple UTC timestamp formatting
496        let days_since_epoch = secs / 86400;
497        let remaining_secs = secs % 86400;
498        let hours = remaining_secs / 3600;
499        let minutes = (remaining_secs % 3600) / 60;
500        let seconds = remaining_secs % 60;
501
502        // Calculate year, month, day from days since epoch (1970-01-01)
503        // Simplified calculation - good enough for build metadata
504        let (year, month, day) = days_to_ymd(days_since_epoch);
505
506        format!(
507            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
508            year, month, day, hours, minutes, seconds
509        )
510    };
511
512    BenchMeta {
513        spec: spec.clone(),
514        commit_hash: get_git_commit(),
515        branch: get_git_branch(),
516        dirty: is_git_dirty(),
517        build_time,
518        build_time_unix: now.as_secs(),
519        target: target.to_string(),
520        profile: profile.to_string(),
521        mobench_version: env!("CARGO_PKG_VERSION").to_string(),
522        rust_version: get_rust_version(),
523        host_os: env::consts::OS.to_string(),
524    }
525}
526
527/// Convert days since epoch to (year, month, day).
528/// Simplified Gregorian calendar calculation.
529fn days_to_ymd(days: u64) -> (i32, u32, u32) {
530    let mut remaining_days = days as i64;
531    let mut year = 1970i32;
532
533    // Advance years
534    loop {
535        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
536        if remaining_days < days_in_year {
537            break;
538        }
539        remaining_days -= days_in_year;
540        year += 1;
541    }
542
543    // Days in each month (non-leap year)
544    let days_in_months: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
545
546    let mut month = 1u32;
547    for (i, &days_in_month) in days_in_months.iter().enumerate() {
548        let mut dim = days_in_month;
549        if i == 1 && is_leap_year(year) {
550            dim = 29;
551        }
552        if remaining_days < dim {
553            break;
554        }
555        remaining_days -= dim;
556        month += 1;
557    }
558
559    (year, month, remaining_days as u32 + 1)
560}
561
562fn is_leap_year(year: i32) -> bool {
563    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
564}
565
566/// Embeds build metadata (bench_meta.json) alongside bench_spec.json in mobile app bundles.
567///
568/// This function creates a `bench_meta.json` file that contains:
569/// - The benchmark specification
570/// - Git commit hash and branch (if available)
571/// - Build timestamp
572/// - Target platform and profile
573/// - mobench and Rust versions
574///
575/// # Arguments
576/// * `output_dir` - The mobench output directory (e.g., `target/mobench`)
577/// * `spec` - The benchmark specification
578/// * `target` - Target platform ("android" or "ios")
579/// * `profile` - Build profile ("debug" or "release")
580pub fn embed_bench_meta(
581    output_dir: &Path,
582    spec: &EmbeddedBenchSpec,
583    target: &str,
584    profile: &str,
585) -> Result<(), BenchError> {
586    let meta = create_bench_meta(spec, target, profile);
587    let meta_json = serde_json::to_string_pretty(&meta)
588        .map_err(|e| BenchError::Build(format!("Failed to serialize bench meta: {}", e)))?;
589
590    // Android: Write to assets directory
591    let android_assets_dir = output_dir.join("android/app/src/main/assets");
592    if output_dir.join("android").exists() {
593        std::fs::create_dir_all(&android_assets_dir).map_err(|e| {
594            BenchError::Build(format!(
595                "Failed to create Android assets directory at {}: {}",
596                android_assets_dir.display(),
597                e
598            ))
599        })?;
600        let android_meta_path = android_assets_dir.join("bench_meta.json");
601        std::fs::write(&android_meta_path, &meta_json).map_err(|e| {
602            BenchError::Build(format!(
603                "Failed to write Android bench meta to {}: {}",
604                android_meta_path.display(),
605                e
606            ))
607        })?;
608    }
609
610    // iOS: Write to Resources directory in the Xcode project
611    let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources");
612    if output_dir.join("ios/BenchRunner").exists() {
613        std::fs::create_dir_all(&ios_resources_dir).map_err(|e| {
614            BenchError::Build(format!(
615                "Failed to create iOS Resources directory at {}: {}",
616                ios_resources_dir.display(),
617                e
618            ))
619        })?;
620        let ios_meta_path = ios_resources_dir.join("bench_meta.json");
621        std::fs::write(&ios_meta_path, &meta_json).map_err(|e| {
622            BenchError::Build(format!(
623                "Failed to write iOS bench meta to {}: {}",
624                ios_meta_path.display(),
625                e
626            ))
627        })?;
628    }
629
630    Ok(())
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn test_get_cargo_target_dir_fallback() {
639        // For a non-existent directory, should fall back gracefully
640        let result = get_cargo_target_dir(Path::new("/nonexistent/path"));
641        // Should either error or return fallback path
642        assert!(result.is_ok() || result.is_err());
643    }
644
645    #[test]
646    fn test_host_lib_path_not_found() {
647        let result = host_lib_path(Path::new("/tmp"), "nonexistent-crate");
648        assert!(result.is_err());
649        let err = result.unwrap_err();
650        let msg = format!("{}", err);
651        assert!(msg.contains("Host library for UniFFI not found"));
652        assert!(msg.contains("cargo build"));
653    }
654
655    #[test]
656    fn test_run_command_not_found() {
657        let cmd = Command::new("nonexistent-command-12345");
658        let result = run_command(cmd, "test command");
659        assert!(result.is_err());
660        let err = result.unwrap_err();
661        let msg = format!("{}", err);
662        assert!(msg.contains("Failed to start"));
663    }
664
665    #[test]
666    fn test_read_package_name_standard() {
667        let temp_dir = std::env::temp_dir().join("mobench-test-read-package");
668        let _ = std::fs::remove_dir_all(&temp_dir);
669        std::fs::create_dir_all(&temp_dir).unwrap();
670
671        let cargo_toml = temp_dir.join("Cargo.toml");
672        std::fs::write(
673            &cargo_toml,
674            r#"[package]
675name = "my-awesome-crate"
676version = "0.1.0"
677edition = "2021"
678
679[dependencies]
680"#,
681        )
682        .unwrap();
683
684        let result = read_package_name(&cargo_toml);
685        assert_eq!(result, Some("my-awesome-crate".to_string()));
686
687        std::fs::remove_dir_all(&temp_dir).unwrap();
688    }
689
690    #[test]
691    fn test_read_package_name_with_single_quotes() {
692        let temp_dir = std::env::temp_dir().join("mobench-test-read-package-sq");
693        let _ = std::fs::remove_dir_all(&temp_dir);
694        std::fs::create_dir_all(&temp_dir).unwrap();
695
696        let cargo_toml = temp_dir.join("Cargo.toml");
697        std::fs::write(
698            &cargo_toml,
699            r#"[package]
700name = 'single-quoted-crate'
701version = "0.1.0"
702"#,
703        )
704        .unwrap();
705
706        let result = read_package_name(&cargo_toml);
707        assert_eq!(result, Some("single-quoted-crate".to_string()));
708
709        std::fs::remove_dir_all(&temp_dir).unwrap();
710    }
711
712    #[test]
713    fn test_read_package_name_not_found() {
714        let result = read_package_name(Path::new("/nonexistent/Cargo.toml"));
715        assert_eq!(result, None);
716    }
717
718    #[test]
719    fn test_read_package_name_no_package_section() {
720        let temp_dir = std::env::temp_dir().join("mobench-test-read-package-no-pkg");
721        let _ = std::fs::remove_dir_all(&temp_dir);
722        std::fs::create_dir_all(&temp_dir).unwrap();
723
724        let cargo_toml = temp_dir.join("Cargo.toml");
725        std::fs::write(
726            &cargo_toml,
727            r#"[workspace]
728members = ["crates/*"]
729"#,
730        )
731        .unwrap();
732
733        let result = read_package_name(&cargo_toml);
734        assert_eq!(result, None);
735
736        std::fs::remove_dir_all(&temp_dir).unwrap();
737    }
738
739    #[test]
740    fn test_create_bench_meta() {
741        let spec = EmbeddedBenchSpec {
742            function: "test_crate::my_benchmark".to_string(),
743            iterations: 100,
744            warmup: 10,
745        };
746
747        let meta = create_bench_meta(&spec, "android", "release");
748
749        assert_eq!(meta.spec.function, "test_crate::my_benchmark");
750        assert_eq!(meta.spec.iterations, 100);
751        assert_eq!(meta.spec.warmup, 10);
752        assert_eq!(meta.target, "android");
753        assert_eq!(meta.profile, "release");
754        assert!(!meta.mobench_version.is_empty());
755        assert!(!meta.host_os.is_empty());
756        assert!(!meta.build_time.is_empty());
757        assert!(meta.build_time_unix > 0);
758        // Build time should be in RFC3339 format (roughly YYYY-MM-DDTHH:MM:SSZ)
759        assert!(meta.build_time.contains('T'));
760        assert!(meta.build_time.ends_with('Z'));
761    }
762
763    #[test]
764    fn test_days_to_ymd_epoch() {
765        // Day 0 should be January 1, 1970
766        let (year, month, day) = days_to_ymd(0);
767        assert_eq!(year, 1970);
768        assert_eq!(month, 1);
769        assert_eq!(day, 1);
770    }
771
772    #[test]
773    fn test_days_to_ymd_known_date() {
774        // January 21, 2026 is approximately 20,474 days since epoch
775        // (2026 - 1970 = 56 years, with leap years)
776        // Let's test a simpler case: 365 days = January 1, 1971
777        let (year, month, day) = days_to_ymd(365);
778        assert_eq!(year, 1971);
779        assert_eq!(month, 1);
780        assert_eq!(day, 1);
781    }
782
783    #[test]
784    fn test_is_leap_year() {
785        assert!(!is_leap_year(1970)); // Not divisible by 4
786        assert!(is_leap_year(2000)); // Divisible by 400
787        assert!(!is_leap_year(1900)); // Divisible by 100 but not 400
788        assert!(is_leap_year(2024)); // Divisible by 4, not by 100
789    }
790
791    #[test]
792    fn test_bench_meta_serialization() {
793        let spec = EmbeddedBenchSpec {
794            function: "my_func".to_string(),
795            iterations: 50,
796            warmup: 5,
797        };
798
799        let meta = create_bench_meta(&spec, "ios", "debug");
800        let json = serde_json::to_string(&meta).expect("serialization should work");
801
802        // Verify it contains expected fields
803        assert!(json.contains("my_func"));
804        assert!(json.contains("ios"));
805        assert!(json.contains("debug"));
806        assert!(json.contains("build_time"));
807        assert!(json.contains("mobench_version"));
808    }
809}