1#![forbid(unsafe_code)]
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12pub const MANIFEST_FILE: &str = "pite.toml";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PiteManifest {
16 #[serde(default)]
17 pub project: ProjectMeta,
18 #[serde(default)]
19 pub export: ExportConfig,
20 #[serde(flatten, default)]
21 pub extra: HashMap<String, toml::Value>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ProjectMeta {
26 #[serde(default = "default_name")]
27 pub name: String,
28 #[serde(default = "default_version")]
29 pub pite_version: String,
30 #[serde(default = "default_main_scene")]
31 pub main_scene: String,
32 #[serde(default = "default_python")]
33 pub python: String,
34 #[serde(default)]
35 pub author: String,
36 #[serde(default)]
37 pub icon: String,
38 #[serde(default = "default_window_width")]
39 pub window_width: u32,
40 #[serde(default = "default_window_height")]
41 pub window_height: u32,
42 #[serde(flatten, default)]
43 pub extra: HashMap<String, toml::Value>,
44}
45
46impl Default for ProjectMeta {
47 fn default() -> Self {
48 Self {
49 name: default_name(),
50 pite_version: default_version(),
51 main_scene: default_main_scene(),
52 python: default_python(),
53 author: String::new(),
54 icon: String::new(),
55 window_width: default_window_width(),
56 window_height: default_window_height(),
57 extra: HashMap::new(),
58 }
59 }
60}
61
62fn default_name() -> String {
63 "my-game".to_string()
64}
65fn default_version() -> String {
66 "0.1.1".to_string()
67}
68fn default_main_scene() -> String {
69 "res://scenes/main.pitescene".to_string()
70}
71fn default_python() -> String {
72 "3.12".to_string()
73}
74fn default_window_width() -> u32 {
75 800
76}
77fn default_window_height() -> u32 {
78 600
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ExportConfig {
86 #[serde(default)]
87 pub binary_name: Option<String>,
88 #[serde(default)]
89 pub include: Vec<String>,
90 #[serde(flatten, default)]
91 pub extra: HashMap<String, toml::Value>,
92}
93
94impl Default for ExportConfig {
95 fn default() -> Self {
96 Self {
97 binary_name: None,
98 include: Vec::new(),
99 extra: HashMap::new(),
100 }
101 }
102}
103
104pub fn load_manifest(dir: &Path) -> Result<PiteManifest> {
105 let path = dir.join(MANIFEST_FILE);
106 let text = std::fs::read_to_string(&path)
107 .with_context(|| format!("cannot read {}", path.display()))?;
108 toml::from_str(&text).with_context(|| format!("cannot parse {}", path.display()))
109}
110
111pub fn deprecated_replacement(field: &str) -> Option<&'static str> {
114 let _ = field;
115 None
116}
117
118pub fn validate_manifest(manifest: &PiteManifest) -> (Vec<String>, Vec<String>) {
121 let mut errors = Vec::new();
122 let mut warnings = Vec::new();
123 let mut unknown: Vec<String> = manifest.extra.keys().cloned().collect();
124 for key in manifest.project.extra.keys() {
125 unknown.push(format!("project.{key}"));
126 }
127 for key in manifest.export.extra.keys() {
128 unknown.push(format!("export.{key}"));
129 }
130 unknown.sort();
131 for field in unknown {
132 if field == "export.platforms" {
133 warnings.push(
134 "manifest: export.platforms is deprecated and ignored (removed in 0.1.1)"
135 .to_string(),
136 );
137 continue;
138 }
139 match deprecated_replacement(&field) {
140 Some(use_instead) => warnings.push(format!(
141 "manifest: {field:?} is deprecated, use {use_instead:?}"
142 )),
143 None => warnings.push(format!("manifest: unknown field {field:?}")),
144 }
145 }
146 if manifest.project.python.is_empty() {
147 errors.push("manifest: project.python must not be empty".to_string());
148 }
149 if manifest.project.window_width == 0 || manifest.project.window_height == 0 {
150 errors.push(format!(
151 "manifest: window is {}x{}, dimensions must be positive",
152 manifest.project.window_width, manifest.project.window_height
153 ));
154 }
155 if !manifest.project.icon.is_empty() && !manifest.project.icon.starts_with("res://") {
156 errors.push(format!(
157 "manifest: icon {:?} must be res:// or empty (absolute paths are forbidden)",
158 manifest.project.icon
159 ));
160 }
161 (errors, warnings)
162}
163
164pub fn find_project_root(start: &Path) -> Option<PathBuf> {
165 let mut dir = if start.is_file() {
166 start.parent()?.to_path_buf()
167 } else {
168 start.to_path_buf()
169 };
170 loop {
171 if dir.join(MANIFEST_FILE).is_file() {
172 return Some(dir);
173 }
174 if !dir.pop() {
175 return None;
176 }
177 }
178}
179
180pub fn resolve_res(project_dir: &Path, res_path: &str) -> Option<PathBuf> {
181 res_path
182 .strip_prefix("res://")
183 .map(|rel| project_dir.join(rel))
184}
185
186pub fn to_res_path(project_dir: &Path, path: &Path) -> Option<String> {
187 path.strip_prefix(project_dir)
188 .ok()
189 .map(|rel| format!("res://{}", rel.to_string_lossy().replace('\\', "/")))
190}
191
192pub const TEMPLATE_PITE_TOML: &str = r#"[project]
193name = "TEMPLATE_NAME"
194pite_version = "0.1.1"
195main_scene = "res://scenes/main.pitescene"
196python = "3.12"
197author = ""
198icon = ""
199window_width = 800
200window_height = 600
201"#;
202
203pub fn template_scene() -> String {
206 format!(
207 r#"format_version = {}
208root = "root"
209
210[[node]]
211id = "root"
212type = "Node2D"
213name = "Main"
214
215[[node]]
216id = "player"
217type = "Sprite2D"
218name = "Player"
219parent = "root"
220
221[node.props]
222texture = "res://assets/player.png"
223
224[node.script]
225path = "res://scripts/player.py"
226class = "Player"
227
228[[node]]
229id = "cam"
230type = "Camera2D"
231name = "Camera"
232parent = "root"
233
234[node.props]
235position = [100.0, 200.0]
236"#,
237 pite_scene::FORMAT_VERSION
238 )
239}
240
241pub const TEMPLATE_SCRIPT: &str = r#"import pite
242
243
244class Player(pite.Node2D):
245 speed: float = 200.0
246
247 def _ready(self):
248 self.position = (100.0, 200.0)
249
250 def _process(self, delta: float):
251 x, y = self.position
252 if pite.held("ArrowRight"):
253 x += self.speed * delta
254 if pite.held("ArrowLeft"):
255 x -= self.speed * delta
256 if pite.held("ArrowDown"):
257 y += self.speed * delta
258 if pite.held("ArrowUp"):
259 y -= self.speed * delta
260 if pite.pressed("Space"):
261 x += 10.0
262 self.position = (x, y)
263"#;
264
265const TEMPLATE_PLAYER_PNG: &[u8] = &[
268 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
269 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 255, 127, 0,
270 9, 251, 3, 253, 42, 134, 227, 138, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
271];
272
273pub fn create_project(dest: &Path, name: &str, template: &str) -> Result<PathBuf> {
274 if template != "minimal-2d" {
275 anyhow::bail!("unknown template {template:?} (only \"minimal-2d\" exists)");
276 }
277 let dir = if dest.file_name().map(|n| n == name).unwrap_or(false) {
278 dest.to_path_buf()
279 } else {
280 dest.join(name)
281 };
282 if dir.exists() {
283 anyhow::bail!("{} already exists", dir.display());
284 }
285 std::fs::create_dir_all(dir.join("scenes")).with_context(|| "cannot create project dirs")?;
286 std::fs::create_dir_all(dir.join("scripts"))?;
287 std::fs::create_dir_all(dir.join("assets"))?;
288 std::fs::write(
289 dir.join(MANIFEST_FILE),
290 TEMPLATE_PITE_TOML.replace("TEMPLATE_NAME", name),
291 )?;
292 std::fs::write(dir.join("scenes").join("main.pitescene"), template_scene())?;
293 std::fs::write(dir.join("scripts").join("player.py"), TEMPLATE_SCRIPT)?;
294 std::fs::write(dir.join("assets").join("player.png"), TEMPLATE_PLAYER_PNG)?;
295 Ok(dir)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::sync::atomic::{AtomicU64, Ordering};
302
303 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
304
305 fn tmpdir(tag: &str) -> PathBuf {
306 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
307 let dir =
308 std::env::temp_dir().join(format!("pite-manifest-{tag}-{}-{n}", std::process::id()));
309 let _ = std::fs::remove_dir_all(&dir);
310 std::fs::create_dir_all(&dir).unwrap();
311 dir
312 }
313
314 fn load_text(text: &str) -> PiteManifest {
315 let dir = tmpdir("parse");
316 std::fs::write(dir.join(MANIFEST_FILE), text).unwrap();
317 let manifest = load_manifest(&dir).unwrap();
318 std::fs::remove_dir_all(&dir).ok();
319 manifest
320 }
321
322 #[test]
323 fn old_manifest_parses_untouched() {
324 let manifest = load_text("[project]\nname = \"old\"\n");
325 assert_eq!(manifest.project.name, "old");
326 assert_eq!(manifest.project.pite_version, "0.1.1");
327 assert_eq!(manifest.project.main_scene, "res://scenes/main.pitescene");
328 assert_eq!(manifest.project.python, "3.12");
329 assert_eq!(manifest.project.author, "");
330 assert_eq!(manifest.project.icon, "");
331 assert_eq!(manifest.project.window_width, 800);
332 assert_eq!(manifest.project.window_height, 600);
333 assert!(manifest.project.extra.is_empty());
334 let (errors, warnings) = validate_manifest(&manifest);
335 assert!(errors.is_empty(), "got {errors:?}");
336 assert!(warnings.is_empty(), "got {warnings:?}");
337 }
338
339 #[test]
340 fn bad_type_fails_loudly() {
341 let dir = tmpdir("bad-type");
342 std::fs::write(dir.join(MANIFEST_FILE), "[project]\nname = 5\n").unwrap();
343 let err = load_manifest(&dir).unwrap_err();
344 assert!(err.to_string().contains("cannot parse"), "got {err:#}");
345 std::fs::write(
346 dir.join(MANIFEST_FILE),
347 "[project]\nname = \"x\"\nwindow_width = \"wide\"\n",
348 )
349 .unwrap();
350 let err = load_manifest(&dir).unwrap_err();
351 assert!(err.to_string().contains("cannot parse"), "got {err:#}");
352 std::fs::remove_dir_all(&dir).ok();
353 }
354
355 #[test]
356 fn unknown_keys_survive_round_trip() {
357 let manifest = load_text(
358 "custom = 1\n\n[project]\nname = \"x\"\nnickname = \"y\"\n\n[export]\nbundle = true\n",
359 );
360 assert_eq!(
361 manifest.project.extra.get("nickname"),
362 Some(&toml::Value::String("y".to_string()))
363 );
364 let again: PiteManifest = toml::from_str(&toml::to_string(&manifest).unwrap()).unwrap();
365 assert_eq!(manifest.extra, again.extra);
366 assert_eq!(manifest.project.extra, again.project.extra);
367 assert_eq!(manifest.export.extra, again.export.extra);
368 let (_, warnings) = validate_manifest(&again);
369 for field in ["custom", "project.nickname", "export.bundle"] {
370 assert!(
371 warnings.iter().any(|w| w.contains(field)),
372 "missing warning for {field}, got: {warnings:?}"
373 );
374 }
375 }
376
377 #[test]
378 fn impossible_values_are_errors() {
379 let manifest = load_text(
380 "[project]\nname = \"x\"\npython = \"\"\nwindow_width = 0\nicon = \"/abs/icon.png\"\n",
381 );
382 let (errors, _) = validate_manifest(&manifest);
383 assert!(
384 errors.iter().any(|e| e.contains("python")),
385 "got {errors:?}"
386 );
387 assert!(errors.iter().any(|e| e.contains("0x")), "got {errors:?}");
388 assert!(errors.iter().any(|e| e.contains("icon")), "got {errors:?}");
389 }
390}