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    // Generated Android/iOS projects include these output-local resources even
334    // before their app scaffolds exist, which keeps clean first runs deterministic.
335    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    // Android: Write to assets directory
358    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    // iOS: Write to Resources directory in the Xcode project
378    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/// Represents a benchmark specification for embedding.
401///
402/// This is a simple struct that can be serialized to JSON and embedded
403/// in mobile app bundles.
404#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
405pub struct EmbeddedBenchSpec {
406    /// The benchmark function name (e.g., "my_crate::my_benchmark")
407    pub function: String,
408    /// Number of benchmark iterations
409    pub iterations: u32,
410    /// Number of warmup iterations
411    pub warmup: u32,
412}
413
414/// Build metadata for artifact correlation and traceability.
415///
416/// This struct captures metadata about the build environment to enable
417/// reproducibility and debugging of benchmark results.
418#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
419pub struct BenchMeta {
420    /// Benchmark specification that was used
421    pub spec: EmbeddedBenchSpec,
422    /// Git commit hash (if in a git repository)
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub commit_hash: Option<String>,
425    /// Git branch name (if available)
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub branch: Option<String>,
428    /// Whether the git working directory was dirty
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub dirty: Option<bool>,
431    /// Build timestamp in RFC3339 format
432    pub build_time: String,
433    /// Build timestamp as Unix epoch seconds
434    pub build_time_unix: u64,
435    /// Target platform ("android" or "ios")
436    pub target: String,
437    /// Build profile ("debug" or "release")
438    pub profile: String,
439    /// mobench version
440    pub mobench_version: String,
441    /// Rust version used for the build
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub rust_version: Option<String>,
444    /// Host OS (e.g., "macos", "linux")
445    pub host_os: String,
446}
447
448/// Gets the current git commit hash (short form).
449pub 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
464/// Gets the current git branch name.
465pub 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
480/// Checks if the git working directory has uncommitted changes.
481pub 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
495/// Gets the Rust version.
496pub 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
508/// Creates a BenchMeta instance with current build information.
509pub 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    // Format as RFC3339
517    let build_time = {
518        let secs = now.as_secs();
519        // Simple UTC timestamp formatting
520        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        // Calculate year, month, day from days since epoch (1970-01-01)
527        // Simplified calculation - good enough for build metadata
528        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
551/// Convert days since epoch to (year, month, day).
552/// Simplified Gregorian calendar calculation.
553fn days_to_ymd(days: u64) -> (i32, u32, u32) {
554    let mut remaining_days = days as i64;
555    let mut year = 1970i32;
556
557    // Advance years
558    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    // Days in each month (non-leap year)
568    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
590/// Embeds build metadata (bench_meta.json) alongside bench_spec.json in mobile app bundles.
591///
592/// This function creates a `bench_meta.json` file that contains:
593/// - The benchmark specification
594/// - Git commit hash and branch (if available)
595/// - Build timestamp
596/// - Target platform and profile
597/// - mobench and Rust versions
598///
599/// # Arguments
600/// * `output_dir` - The mobench output directory (e.g., `target/mobench`)
601/// * `spec` - The benchmark specification
602/// * `target` - Target platform ("android" or "ios")
603/// * `profile` - Build profile ("debug" or "release")
604pub 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    // Android: Write to assets directory
615    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    // iOS: Write to Resources directory in the Xcode project
635    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        // For a non-existent directory, should fall back gracefully
664        let result = get_cargo_target_dir(Path::new("/nonexistent/path"));
665        // Should either error or return fallback path
666        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        // Build time should be in RFC3339 format (roughly YYYY-MM-DDTHH:MM:SSZ)
783        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        let spec = EmbeddedBenchSpec {
795            function: "test_crate::first_run".to_string(),
796            iterations: 7,
797            warmup: 1,
798        };
799
800        embed_bench_spec(&temp_dir, &spec).expect("embed spec");
801
802        let android_spec = temp_dir.join("target/mobile-spec/android/bench_spec.json");
803        let ios_spec = temp_dir.join("target/mobile-spec/ios/bench_spec.json");
804        assert!(
805            android_spec.exists(),
806            "Android Gradle templates read this first-run spec path"
807        );
808        assert!(
809            ios_spec.exists(),
810            "iOS project templates read this first-run spec path"
811        );
812
813        let contents = std::fs::read_to_string(android_spec).unwrap();
814        assert!(contents.contains("test_crate::first_run"));
815
816        std::fs::remove_dir_all(&temp_dir).unwrap();
817    }
818
819    #[test]
820    fn test_days_to_ymd_epoch() {
821        // Day 0 should be January 1, 1970
822        let (year, month, day) = days_to_ymd(0);
823        assert_eq!(year, 1970);
824        assert_eq!(month, 1);
825        assert_eq!(day, 1);
826    }
827
828    #[test]
829    fn test_days_to_ymd_known_date() {
830        // January 21, 2026 is approximately 20,474 days since epoch
831        // (2026 - 1970 = 56 years, with leap years)
832        // Let's test a simpler case: 365 days = January 1, 1971
833        let (year, month, day) = days_to_ymd(365);
834        assert_eq!(year, 1971);
835        assert_eq!(month, 1);
836        assert_eq!(day, 1);
837    }
838
839    #[test]
840    fn test_is_leap_year() {
841        assert!(!is_leap_year(1970)); // Not divisible by 4
842        assert!(is_leap_year(2000)); // Divisible by 400
843        assert!(!is_leap_year(1900)); // Divisible by 100 but not 400
844        assert!(is_leap_year(2024)); // Divisible by 4, not by 100
845    }
846
847    #[test]
848    fn test_bench_meta_serialization() {
849        let spec = EmbeddedBenchSpec {
850            function: "my_func".to_string(),
851            iterations: 50,
852            warmup: 5,
853        };
854
855        let meta = create_bench_meta(&spec, "ios", "debug");
856        let json = serde_json::to_string(&meta).expect("serialization should work");
857
858        // Verify it contains expected fields
859        assert!(json.contains("my_func"));
860        assert!(json.contains("ios"));
861        assert!(json.contains("debug"));
862        assert!(json.contains("build_time"));
863        assert!(json.contains("mobench_version"));
864    }
865}