use prometeu_hal::app_mode::AppMode; use prometeu_hal::cartridge::CartridgeManifest; use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; #[derive(Debug, Clone)] pub struct GameLibraryEntry { pub manifest: CartridgeManifest, pub title: String, pub app_id: u32, pub app_version: String, pub path: PathBuf, pub discovered_at: SystemTime, } impl GameLibraryEntry { fn new(path: PathBuf, manifest: CartridgeManifest, discovered_at: SystemTime) -> Self { Self { title: manifest.title.clone(), app_id: manifest.app_id, app_version: manifest.app_version.clone(), manifest, path, discovered_at, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct GameLibraryDiagnostic { pub path: PathBuf, pub reason: GameLibraryDiagnosticReason, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum GameLibraryDiagnosticReason { RootNotDirectory, CandidateReadFailed, MissingManifest, InvalidManifest, UnsupportedVersion, MissingProgram, NonGameAppMode, } #[derive(Debug, Clone, Default)] pub struct GameLibrary { entries: Vec, diagnostics: Vec, } impl GameLibrary { pub fn empty() -> Self { Self::default() } pub fn entries(&self) -> &[GameLibraryEntry] { &self.entries } pub fn diagnostics(&self) -> &[GameLibraryDiagnostic] { &self.diagnostics } pub fn is_empty(&self) -> bool { self.entries.is_empty() } } pub fn discover_games_root(root: impl AsRef) -> GameLibrary { let root = root.as_ref(); let discovered_at = SystemTime::now(); if !root.is_dir() { return GameLibrary { entries: Vec::new(), diagnostics: vec![GameLibraryDiagnostic { path: root.to_path_buf(), reason: GameLibraryDiagnosticReason::RootNotDirectory, }], }; } let read_dir = match fs::read_dir(root) { Ok(read_dir) => read_dir, Err(_) => { return GameLibrary { entries: Vec::new(), diagnostics: vec![GameLibraryDiagnostic { path: root.to_path_buf(), reason: GameLibraryDiagnosticReason::CandidateReadFailed, }], }; } }; let mut entries = Vec::new(); let mut diagnostics = Vec::new(); for item in read_dir { let Ok(item) = item else { diagnostics.push(GameLibraryDiagnostic { path: root.to_path_buf(), reason: GameLibraryDiagnosticReason::CandidateReadFailed, }); continue; }; let path = item.path(); if !path.is_dir() { continue; } match discover_candidate(&path, discovered_at) { Ok(entry) => entries.push(entry), Err(reason) => diagnostics.push(GameLibraryDiagnostic { path, reason }), } } entries.sort_by(|left, right| { left.title .cmp(&right.title) .then_with(|| left.app_id.cmp(&right.app_id)) .then_with(|| left.path.cmp(&right.path)) }); GameLibrary { entries, diagnostics } } fn discover_candidate( path: &Path, discovered_at: SystemTime, ) -> Result { let manifest_path = path.join("manifest.json"); if !manifest_path.exists() { return Err(GameLibraryDiagnosticReason::MissingManifest); } let manifest_content = fs::read_to_string(manifest_path) .map_err(|_| GameLibraryDiagnosticReason::InvalidManifest)?; let manifest: CartridgeManifest = serde_json::from_str(&manifest_content) .map_err(|_| GameLibraryDiagnosticReason::InvalidManifest)?; if manifest.magic != "PMTU" { return Err(GameLibraryDiagnosticReason::InvalidManifest); } if manifest.cartridge_version != 1 { return Err(GameLibraryDiagnosticReason::UnsupportedVersion); } if manifest.app_mode != AppMode::Game { return Err(GameLibraryDiagnosticReason::NonGameAppMode); } if !path.join("program.pbx").exists() { return Err(GameLibraryDiagnosticReason::MissingProgram); } Ok(GameLibraryEntry::new(path.to_path_buf(), manifest, discovered_at)) } #[cfg(test)] mod tests { use super::*; use serde_json::json; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::UNIX_EPOCH; static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); struct TestRoot { path: PathBuf, } impl TestRoot { fn new() -> Self { let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "prometeu-game-library-test-{}-{}", std::process::id(), unique )); fs::create_dir_all(&path).expect("test root should be created"); Self { path } } fn add_candidate(&self, name: &str, manifest: serde_json::Value, with_program: bool) { let candidate = self.path.join(name); fs::create_dir_all(&candidate).expect("candidate should be created"); fs::write(candidate.join("manifest.json"), manifest.to_string()) .expect("manifest should be written"); if with_program { fs::write(candidate.join("program.pbx"), [0_u8]) .expect("program should be written"); } } } impl Drop for TestRoot { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } fn manifest(title: &str, app_id: u32, app_mode: &str) -> serde_json::Value { json!({ "magic": "PMTU", "cartridge_version": 1, "app_id": app_id, "title": title, "app_version": "1.0.0", "app_mode": app_mode }) } #[test] fn discovery_returns_only_immediate_valid_game_directories() { let root = TestRoot::new(); root.add_candidate("zeta", manifest("Zeta", 2, "Game"), true); root.add_candidate("alpha", manifest("Alpha", 1, "Game"), true); root.add_candidate("shell", manifest("Shell", 3, "Shell"), true); root.add_candidate("missing-program", manifest("Missing Program", 4, "Game"), false); fs::write(root.path.join("loose-file.txt"), "ignored").expect("file should be written"); let nested_parent = root.path.join("nested-parent"); fs::create_dir_all(nested_parent.join("nested-game")).expect("nested dir should exist"); fs::write( nested_parent.join("nested-game").join("manifest.json"), manifest("Nested", 5, "Game").to_string(), ) .expect("nested manifest should be written"); fs::write(nested_parent.join("nested-game").join("program.pbx"), [0_u8]) .expect("nested program should be written"); let library = discover_games_root(&root.path); assert_eq!(library.entries().len(), 2); assert_eq!(library.entries()[0].title, "Alpha"); assert_eq!(library.entries()[0].app_id, 1); assert_eq!(library.entries()[0].app_version, "1.0.0"); assert_eq!(library.entries()[0].manifest.app_mode, AppMode::Game); assert!(library.entries()[0].path.ends_with("alpha")); assert!(library.entries()[0].discovered_at >= UNIX_EPOCH); assert_eq!(library.entries()[1].title, "Zeta"); assert!(library.diagnostics().iter().any(|diagnostic| { diagnostic.path.ends_with("shell") && diagnostic.reason == GameLibraryDiagnosticReason::NonGameAppMode })); assert!(library.diagnostics().iter().any(|diagnostic| { diagnostic.path.ends_with("missing-program") && diagnostic.reason == GameLibraryDiagnosticReason::MissingProgram })); assert!(library.diagnostics().iter().any(|diagnostic| { diagnostic.path.ends_with("nested-parent") && diagnostic.reason == GameLibraryDiagnosticReason::MissingManifest })); } #[test] fn missing_root_returns_diagnostic_and_no_entries() { let root = TestRoot::new(); let missing = root.path.join("missing"); let library = discover_games_root(&missing); assert!(library.is_empty()); assert_eq!(library.diagnostics().len(), 1); assert_eq!(library.diagnostics()[0].reason, GameLibraryDiagnosticReason::RootNotDirectory); } }