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