Files
Oxide/engine/src/project.rs
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

400 lines
13 KiB
Rust

//! Projects: the on-disk unit a game is authored as.
//!
//! A **project** is a root directory containing a project file plus a defined
//! folder layout (scenes, assets, scripts). The project file (RON) records the
//! project name, the engine version it was made with, the set of enabled
//! [modules](crate::app::Module), and per-project settings. The format lives in
//! the engine — not the editor — because the exported runtime and the Stage-16
//! packer read it too; the editor adds the create/open/save UI on top.
//!
//! Per-project settings are stored as **opaque per-section RON blobs**
//! (`section name → RON`), so this module stays independent of the typed
//! settings framework: that framework serializes its typed sections to these
//! strings and back.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The project file's name within the project root.
pub const PROJECT_FILE_NAME: &str = "project.oxide";
/// The subdirectory holding scene files.
pub const SCENES_DIR: &str = "scenes";
/// The subdirectory holding asset files (meshes, textures, audio, …).
pub const ASSETS_DIR: &str = "assets";
/// The subdirectory holding game scripts.
pub const SCRIPTS_DIR: &str = "scripts";
/// Errors from project operations.
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
/// A project file already exists where a new project was to be created.
#[error("a project already exists at {0}")]
AlreadyExists(PathBuf),
/// No project file was found at the given location.
#[error("no project file found at {0}")]
NotFound(PathBuf),
/// Filesystem I/O failed.
#[error("project i/o error at {path}: {source}")]
Io {
/// The path involved.
path: PathBuf,
/// The underlying error.
source: std::io::Error,
},
/// The project file could not be parsed.
#[error("malformed project file at {path}: {message}")]
Parse {
/// The project file path.
path: PathBuf,
/// The parser message.
message: String,
},
/// The project file could not be serialized.
#[error("failed to serialize project: {0}")]
Serialize(String),
}
/// The serialized contents of a project file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectMeta {
/// Human-readable project name.
pub name: String,
/// The engine version this project was last saved with.
pub engine_version: String,
/// Names of the modules enabled for this project.
pub enabled_modules: Vec<String>,
/// Per-project settings as opaque RON blobs, keyed by section name. The
/// typed settings framework round-trips its sections through here.
pub settings: BTreeMap<String, String>,
}
impl ProjectMeta {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
engine_version: env!("CARGO_PKG_VERSION").to_string(),
enabled_modules: Vec::new(),
settings: BTreeMap::new(),
}
}
}
/// An open project: its root directory plus the loaded [`ProjectMeta`].
#[derive(Debug, Clone)]
pub struct Project {
root: PathBuf,
meta: ProjectMeta,
}
impl Project {
/// Creates a new project rooted at `root` (created if missing), scaffolding
/// the `scenes`/`assets`/`scripts` folders and writing the project file.
///
/// # Errors
/// [`AlreadyExists`](ProjectError::AlreadyExists) if a project file is
/// already present, or [`Io`](ProjectError::Io) on filesystem failure.
pub fn create(root: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, ProjectError> {
let root = root.as_ref().to_path_buf();
let file = root.join(PROJECT_FILE_NAME);
if file.exists() {
return Err(ProjectError::AlreadyExists(file));
}
for dir in [
&root,
&root.join(SCENES_DIR),
&root.join(ASSETS_DIR),
&root.join(SCRIPTS_DIR),
] {
std::fs::create_dir_all(dir).map_err(|source| ProjectError::Io {
path: dir.clone(),
source,
})?;
}
let project = Self {
root,
meta: ProjectMeta::new(name),
};
project.save()?;
Ok(project)
}
/// Opens an existing project. `path` may be the project root directory or
/// the project file itself.
///
/// # Errors
/// [`NotFound`](ProjectError::NotFound) if no project file is present, or
/// [`Parse`](ProjectError::Parse)/[`Io`](ProjectError::Io) on failure.
pub fn open(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
let path = path.as_ref();
let (root, file) = if path.is_dir() {
(path.to_path_buf(), path.join(PROJECT_FILE_NAME))
} else {
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
(root, path.to_path_buf())
};
if !file.exists() {
return Err(ProjectError::NotFound(file));
}
let text = std::fs::read_to_string(&file).map_err(|source| ProjectError::Io {
path: file.clone(),
source,
})?;
let meta: ProjectMeta = ron::from_str(&text).map_err(|err| ProjectError::Parse {
path: file.clone(),
message: err.to_string(),
})?;
Ok(Self { root, meta })
}
/// Writes the project file, stamping it with the current engine version.
pub fn save(&self) -> Result<(), ProjectError> {
let file = self.project_file_path();
let pretty = ron::ser::PrettyConfig::default();
let text = ron::ser::to_string_pretty(&self.meta, pretty)
.map_err(|err| ProjectError::Serialize(err.to_string()))?;
std::fs::write(&file, text).map_err(|source| ProjectError::Io { path: file, source })
}
// --- Layout ------------------------------------------------------------
/// The project root directory.
pub fn root(&self) -> &Path {
&self.root
}
/// The path of the project file.
pub fn project_file_path(&self) -> PathBuf {
self.root.join(PROJECT_FILE_NAME)
}
/// The scenes directory.
pub fn scenes_dir(&self) -> PathBuf {
self.root.join(SCENES_DIR)
}
/// The assets directory.
pub fn assets_dir(&self) -> PathBuf {
self.root.join(ASSETS_DIR)
}
/// The scripts directory.
pub fn scripts_dir(&self) -> PathBuf {
self.root.join(SCRIPTS_DIR)
}
// --- Metadata ----------------------------------------------------------
/// The project's metadata (name, modules, settings).
pub fn meta(&self) -> &ProjectMeta {
&self.meta
}
/// The project name.
pub fn name(&self) -> &str {
&self.meta.name
}
/// Renames the project (call [`save`](Self::save) to persist).
pub fn set_name(&mut self, name: impl Into<String>) {
self.meta.name = name.into();
}
/// Whether `module` is enabled for this project.
pub fn is_module_enabled(&self, module: &str) -> bool {
self.meta.enabled_modules.iter().any(|m| m == module)
}
/// Enables `module` (no-op if already enabled).
pub fn enable_module(&mut self, module: impl Into<String>) {
let module = module.into();
if !self.is_module_enabled(&module) {
self.meta.enabled_modules.push(module);
}
}
/// Disables `module`. Returns whether it was enabled.
pub fn disable_module(&mut self, module: &str) -> bool {
let before = self.meta.enabled_modules.len();
self.meta.enabled_modules.retain(|m| m != module);
self.meta.enabled_modules.len() != before
}
/// The raw RON blob stored for settings `section`, if any.
pub fn settings_section(&self, section: &str) -> Option<&str> {
self.meta.settings.get(section).map(String::as_str)
}
/// Stores a raw RON blob for settings `section`.
pub fn set_settings_section(&mut self, section: impl Into<String>, ron: impl Into<String>) {
self.meta.settings.insert(section.into(), ron.into());
}
}
/// A most-recently-used list of project roots, persisted globally (an editor
/// preference, not part of any single project).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecentProjects {
entries: Vec<PathBuf>,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
10
}
impl RecentProjects {
/// A list retaining at most `limit` entries.
pub fn new(limit: usize) -> Self {
Self {
entries: Vec::new(),
limit: limit.max(1),
}
}
/// Records `root` as the most recent project, de-duplicating and capping.
pub fn record(&mut self, root: impl AsRef<Path>) {
let root = root.as_ref().to_path_buf();
self.entries.retain(|p| p != &root);
self.entries.insert(0, root);
self.entries.truncate(self.limit.max(1));
}
/// The recorded roots, most-recent first.
pub fn entries(&self) -> &[PathBuf] {
&self.entries
}
/// Loads the list from a RON file, or returns an empty list if absent.
pub fn load(path: impl AsRef<Path>) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|text| ron::from_str(&text).ok())
.unwrap_or_default()
}
/// Saves the list to a RON file.
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(path, text)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"oxide_project_test_{}_{}_{tag}",
std::process::id(),
// A counter to keep tests isolated within the process.
COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
));
path
}
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
#[test]
fn create_scaffolds_layout_and_file() {
let root = temp_root("create");
let project = Project::create(&root, "My Game").unwrap();
assert!(project.project_file_path().exists());
assert!(project.scenes_dir().is_dir());
assert!(project.assets_dir().is_dir());
assert!(project.scripts_dir().is_dir());
assert_eq!(project.name(), "My Game");
std::fs::remove_dir_all(root).ok();
}
#[test]
fn create_then_open_round_trips() {
let root = temp_root("roundtrip");
let mut project = Project::create(&root, "Game").unwrap();
project.enable_module("physics");
project.enable_module("audio");
project.set_settings_section("editor", "(theme:\"dark\")");
project.save().unwrap();
// Open by directory.
let opened = Project::open(&root).unwrap();
assert_eq!(opened.name(), "Game");
assert!(opened.is_module_enabled("physics") && opened.is_module_enabled("audio"));
assert_eq!(opened.settings_section("editor"), Some("(theme:\"dark\")"));
// Open by file path.
let by_file = Project::open(opened.project_file_path()).unwrap();
assert_eq!(by_file.meta(), opened.meta());
std::fs::remove_dir_all(root).ok();
}
#[test]
fn create_refuses_to_overwrite() {
let root = temp_root("nooverwrite");
Project::create(&root, "A").unwrap();
let err = Project::create(&root, "B").unwrap_err();
assert!(matches!(err, ProjectError::AlreadyExists(_)));
std::fs::remove_dir_all(root).ok();
}
#[test]
fn open_missing_is_not_found() {
let root = temp_root("missing");
let err = Project::open(&root).unwrap_err();
assert!(matches!(err, ProjectError::NotFound(_)));
}
#[test]
fn module_enable_disable() {
let root = temp_root("modules");
let mut project = Project::create(&root, "M").unwrap();
project.enable_module("terrain");
project.enable_module("terrain"); // idempotent
assert_eq!(project.meta().enabled_modules, vec!["terrain"]);
assert!(project.disable_module("terrain"));
assert!(!project.disable_module("terrain"));
assert!(!project.is_module_enabled("terrain"));
std::fs::remove_dir_all(root).ok();
}
#[test]
fn recent_projects_dedup_and_cap() {
let mut recent = RecentProjects::new(3);
recent.record("/a");
recent.record("/b");
recent.record("/a"); // moves /a to front, no dup
recent.record("/c");
recent.record("/d"); // evicts the oldest (/b)
let entries: Vec<_> = recent
.entries()
.iter()
.map(|p| p.to_str().unwrap())
.collect();
assert_eq!(entries, vec!["/d", "/c", "/a"]);
}
#[test]
fn recent_projects_persist() {
let root = temp_root("recent");
std::fs::create_dir_all(&root).unwrap();
let file = root.join("recent.ron");
let mut recent = RecentProjects::new(5);
recent.record("/x");
recent.record("/y");
recent.save(&file).unwrap();
let loaded = RecentProjects::load(&file);
assert_eq!(loaded.entries(), recent.entries());
std::fs::remove_dir_all(root).ok();
}
}