mecha10-core 0.8.2

Mecha10 core runtime and foundational types
Documentation
//! Filesystem utility helpers shared across binaries (CLI, launcher-service, ...).
//!
//! These helpers were previously duplicated locally in each binary's cache-cleanup
//! code; they live here so there's a single implementation to maintain.

use std::path::{Path, PathBuf};

pub const PROJECT_CONFIG_FILE: &str = "mecha10.json";

pub const MECHA10_DIR: &str = ".mecha10";

pub const LAUNCHER_DIR: &str = ".mecha10/launcher";

pub const ROBOTS_DIR: &str = "mecha10/robots";

/// Find the root of a Mecha10 project by walking up from `start` looking for a directory
/// containing `mecha10.json`.
///
/// This is the single canonical project-root-discovery algorithm for the whole workspace -
/// the search is deliberately unbounded (never capped to a fixed number of parent hops),
/// since a project can legitimately be invoked from arbitrarily nested subdirectories (e.g. a
/// node process spawned several directories below the project root).
///
/// Returns the first ancestor of `start` (inclusive of `start` itself) that contains a
/// `mecha10.json`, or `None` if no ancestor does (i.e. the search reached the filesystem root
/// without finding one).
///
/// Callers that need symlinks resolved before searching (e.g. to match a canonicalized
/// starting path) should canonicalize `start` themselves before calling this function - it
/// performs no canonicalization on its own.
///
/// # Example
///
/// ```
/// use mecha10_core::fs_utils::find_project_root;
/// use std::fs;
///
/// let dir = tempfile::tempdir().unwrap();
/// fs::write(dir.path().join("mecha10.json"), "{}").unwrap();
/// let nested = dir.path().join("a").join("b").join("c");
/// fs::create_dir_all(&nested).unwrap();
///
/// assert_eq!(find_project_root(&nested), Some(dir.path().to_path_buf()));
/// ```
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
    let mut current = start.to_path_buf();
    loop {
        if current.join(PROJECT_CONFIG_FILE).exists() {
            return Some(current);
        }
        if !current.pop() {
            return None;
        }
    }
}

/// Build a path rooted at the user's home directory by joining the given segments.
///
/// Falls back to `.` (the current directory) if the home directory cannot be
/// determined, matching the previous ad-hoc `dirs::home_dir().unwrap_or_else(...)`
/// behavior duplicated across the CLI, auth, and launcher-service crates.
///
/// # Example
///
/// ```
/// use mecha10_core::fs_utils::home_subpath;
///
/// let path = home_subpath(&[".mecha10", "credentials.json"]);
/// assert!(path.ends_with(".mecha10/credentials.json"));
/// ```
pub fn home_subpath(segments: &[&str]) -> PathBuf {
    let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    for segment in segments {
        path.push(segment);
    }
    path
}

/// Calculate the total size (in bytes) of a directory, recursively.
///
/// Unreadable directories/entries are silently skipped (best-effort accounting
/// for cache-cleanup UX, not a strict filesystem audit).
pub fn dir_size(path: &Path) -> u64 {
    let mut total = 0;
    if let Ok(entries) = std::fs::read_dir(path) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                total += dir_size(&path);
            } else if let Ok(metadata) = path.metadata() {
                total += metadata.len();
            }
        }
    }
    total
}

/// Format a byte count as a human-readable string (e.g. `1.5 MB`).
pub fn format_size(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = 1024 * KB;
    const GB: u64 = 1024 * MB;

    if bytes >= GB {
        format!("{:.1} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.1} KB", bytes as f64 / KB as f64)
    } else {
        format!("{} B", bytes)
    }
}

/// Read a JSON file at `path` and deserialize it into `T`.
///
/// `CalibrationData::load` (`mecha10-driver-imu`) and `MotorCalibration::load`
/// (`mecha10-driver-motor`) each had their own copy of this "read file, deserialize JSON"
/// sequence; cargo-dupes flagged them as near-duplicates. This factors it out once, generic
/// over both the deserialized type and the caller's error type since each crate uses a
/// different `Result` alias (`anyhow::Result` vs. `mecha10_core::error::Result`).
pub fn load_json<T, E>(path: &Path) -> std::result::Result<T, E>
where
    T: serde::de::DeserializeOwned,
    E: From<std::io::Error> + From<serde_json::Error>,
{
    let content = std::fs::read_to_string(path)?;
    let value = serde_json::from_str(&content)?;
    Ok(value)
}

/// Serialize `value` as pretty-printed JSON and write it to `path`.
///
/// The save-side counterpart to [`load_json`] - see its docs for why this is generic over the
/// caller's error type.
pub fn save_json<T, E>(value: &T, path: &Path) -> std::result::Result<(), E>
where
    T: serde::Serialize + ?Sized,
    E: From<std::io::Error> + From<serde_json::Error>,
{
    let content = serde_json::to_string_pretty(value)?;
    std::fs::write(path, content)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
    struct SampleRecord {
        name: String,
        value: u32,
    }

    #[test]
    fn save_json_then_load_json_round_trips() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("record.json");
        let record = SampleRecord {
            name: "gyro".to_string(),
            value: 42,
        };

        save_json::<_, anyhow::Error>(&record, &path).expect("save_json should succeed");
        let loaded: anyhow::Result<SampleRecord> = load_json(&path);

        assert_eq!(loaded.expect("load_json should succeed"), record);
    }

    #[test]
    fn load_json_propagates_io_error_for_missing_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("missing.json");

        let result: anyhow::Result<SampleRecord> = load_json(&path);
        assert!(result.is_err());
    }

    #[test]
    fn load_json_propagates_deserialization_error_for_invalid_json() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("invalid.json");
        fs::write(&path, "not valid json").unwrap();

        let result: anyhow::Result<SampleRecord> = load_json(&path);
        assert!(result.is_err());
    }

    #[test]
    fn format_size_formats_by_magnitude() {
        assert_eq!(format_size(0), "0 B");
        assert_eq!(format_size(512), "512 B");
        assert_eq!(format_size(1024), "1.0 KB");
        assert_eq!(format_size(1024 * 1024), "1.0 MB");
        assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
        assert_eq!(format_size(1536 * 1024 * 1024), "1.5 GB");
    }

    #[test]
    fn dir_size_sums_nested_files() {
        let dir = tempfile::tempdir().expect("tempdir");
        fs::write(dir.path().join("a.txt"), b"hello").unwrap();
        let nested = dir.path().join("nested");
        fs::create_dir(&nested).unwrap();
        fs::write(nested.join("b.txt"), b"world!").unwrap();

        assert_eq!(dir_size(dir.path()), 5 + 6);
    }

    #[test]
    fn dir_size_missing_dir_is_zero() {
        assert_eq!(dir_size(Path::new("/nonexistent/does/not/exist")), 0);
    }

    #[test]
    fn find_project_root_finds_config_in_starting_dir() {
        let dir = tempfile::tempdir().expect("tempdir");
        fs::write(dir.path().join(PROJECT_CONFIG_FILE), "{}").unwrap();

        assert_eq!(find_project_root(dir.path()), Some(dir.path().to_path_buf()));
    }

    #[test]
    fn find_project_root_walks_up_arbitrarily_deep_nesting() {
        let dir = tempfile::tempdir().expect("tempdir");
        fs::write(dir.path().join(PROJECT_CONFIG_FILE), "{}").unwrap();

        let nested = dir.path().join("a").join("b").join("c").join("d").join("e");
        fs::create_dir_all(&nested).unwrap();

        assert_eq!(find_project_root(&nested), Some(dir.path().to_path_buf()));
    }

    #[test]
    fn find_project_root_returns_none_when_not_found() {
        let dir = tempfile::tempdir().expect("tempdir");
        let nested = dir.path().join("a").join("b");
        fs::create_dir_all(&nested).unwrap();

        assert_eq!(find_project_root(&nested), None);
    }

    #[test]
    #[cfg(unix)]
    fn find_project_root_follows_symlinked_path_components() {
        let project = tempfile::tempdir().expect("tempdir");
        fs::write(project.path().join(PROJECT_CONFIG_FILE), "{}").unwrap();
        let real_nested = project.path().join("a").join("b");
        fs::create_dir_all(&real_nested).unwrap();

        let link_dir = tempfile::tempdir().expect("tempdir");
        let link = link_dir.path().join("link");
        std::os::unix::fs::symlink(project.path(), &link).unwrap();

        let start = link.join("a").join("b");
        assert!(start.exists(), "symlinked path should resolve to the real nested dir");

        let found = find_project_root(&start).expect("should find project root through symlink");
        assert_eq!(found, link);
    }
}