2026-07-03 17:26:00 +01:00

271 lines
8.5 KiB
Rust

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<GameLibraryEntry>,
diagnostics: Vec<GameLibraryDiagnostic>,
}
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<Path>) -> 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<GameLibraryEntry, GameLibraryDiagnosticReason> {
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);
}
}