//! 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, /// Per-project settings as opaque RON blobs, keyed by section name. The /// typed settings framework round-trips its sections through here. pub settings: BTreeMap, } impl ProjectMeta { fn new(name: impl Into) -> 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, name: impl Into) -> Result { 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) -> Result { 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) { 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) { 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, ron: impl Into) { 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, #[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) { 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) -> 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) -> 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(); } }