271 lines
8.5 KiB
Rust
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);
|
|
}
|
|
}
|