mecha10-core 0.6.3

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};

/// Filename of the project manifest that marks the root of a Mecha10 project.
pub const PROJECT_CONFIG_FILE: &str = "mecha10.json";

/// Base mecha10 directory in the user's home directory (`~/.mecha10`).
///
/// This is the single source of truth for the `.mecha10` literal - both
/// `mecha10-cli-core::paths::user::MECHA10_DIR` (used by the dev CLI, which depends on this
/// crate) and `launcher-service` (which deliberately does *not* depend on `mecha10-cli-core` -
/// same dependency-boundary reasoning as `node-resolver`, to keep a binary meant to run
/// standalone on a robot free of redis/mecha10-auth/dev-CLI-shaped dependencies, but which can
/// depend on this lighter-weight crate) reference this constant rather than each hardcoding
/// their own copy of the literal (LAB-1999).
pub const MECHA10_DIR: &str = ".mecha10";

/// The launcher's state subtree, relative to the user's home directory (`~/.mecha10/launcher`).
///
/// Holds the launcher service's config, logs, and downloaded binary cache.
pub const LAUNCHER_DIR: &str = ".mecha10/launcher";

/// Directory where launched robot projects are installed, relative to the user's home directory
/// (`~/mecha10/robots`).
///
/// Deliberately *not* under `~/.mecha10` - this is confirmed intentional, founding design (see
/// LAB-1999), not drift.
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)
    }
}

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

    #[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();

        // Deliberately deeper than any fixed-depth probe (previously capped at 2 hops).
        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();

        // No mecha10.json anywhere under `dir`, and `dir` itself is a standalone tempdir not
        // nested under a project, so the walk-up should exhaust and return None.
        assert_eq!(find_project_root(&nested), None);
    }

    #[test]
    #[cfg(unix)]
    fn find_project_root_follows_symlinked_path_components() {
        // The project root itself, plus a real nested subdirectory under it.
        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();

        // A symlink, stored elsewhere, whose target is the project root.
        let link_dir = tempfile::tempdir().expect("tempdir");
        let link = link_dir.path().join("link");
        std::os::unix::fs::symlink(project.path(), &link).unwrap();

        // Start the search from a path that references the real nested directory only through
        // the symlinked component (`link/a/b`, not `<project>/a/b`). `find_project_root` never
        // calls `canonicalize()` itself, but each `.join(...).exists()` check still resolves
        // symlinks that appear in the *middle* of a path (the OS does this transparently), so
        // walking up from here reaches `link/mecha10.json`, which resolves to the real file.
        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);
    }
}