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(default)]
25 pub vendor: HashMap<String, VendorEntry>,
26 #[serde(flatten, default)]
27 pub extra: HashMap<String, toml::Value>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProjectMeta {
32 #[serde(default = "default_name")]
33 pub name: String,
34 #[serde(default = "default_version")]
35 pub pite_version: String,
36 #[serde(default = "default_main_scene")]
37 pub main_scene: String,
38 #[serde(default = "default_python")]
39 pub python: String,
40 #[serde(default)]
41 pub author: String,
42 #[serde(default)]
43 pub icon: String,
44 #[serde(default = "default_window_width")]
45 pub window_width: u32,
46 #[serde(default = "default_window_height")]
47 pub window_height: u32,
48 #[serde(flatten, default)]
49 pub extra: HashMap<String, toml::Value>,
50}
51
52impl Default for ProjectMeta {
53 fn default() -> Self {
54 Self {
55 name: default_name(),
56 pite_version: default_version(),
57 main_scene: default_main_scene(),
58 python: default_python(),
59 author: String::new(),
60 icon: String::new(),
61 window_width: default_window_width(),
62 window_height: default_window_height(),
63 extra: HashMap::new(),
64 }
65 }
66}
67
68fn default_name() -> String {
69 "my-game".to_string()
70}
71fn default_version() -> String {
72 "0.2.0".to_string()
73}
74fn default_main_scene() -> String {
75 "res://scenes/main.pitescene".to_string()
76}
77fn default_python() -> String {
78 "3.12".to_string()
79}
80fn default_window_width() -> u32 {
81 1280
82}
83fn default_window_height() -> u32 {
84 720
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ExportConfig {
92 #[serde(default)]
93 pub binary_name: Option<String>,
94 #[serde(default)]
95 pub include: Vec<String>,
96 #[serde(flatten, default)]
97 pub extra: HashMap<String, toml::Value>,
98}
99
100impl Default for ExportConfig {
101 fn default() -> Self {
102 Self {
103 binary_name: None,
104 include: Vec::new(),
105 extra: HashMap::new(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct VendorEntry {
114 pub version: String,
115 pub sha256: String,
116 #[serde(default)]
118 pub direct: bool,
119}
120
121pub fn load_manifest(dir: &Path) -> Result<PiteManifest> {
122 let path = dir.join(MANIFEST_FILE);
123 let text = std::fs::read_to_string(&path)
124 .with_context(|| format!("cannot read {}", path.display()))?;
125 toml::from_str(&text).with_context(|| format!("cannot parse {}", path.display()))
126}
127
128pub fn save_manifest(dir: &Path, manifest: &PiteManifest) -> Result<()> {
132 let path = dir.join(MANIFEST_FILE);
133 let value =
134 toml::Value::try_from(manifest).with_context(|| "cannot encode manifest as TOML")?;
135 let mut text =
136 toml::to_string_pretty(&value).with_context(|| "cannot render manifest as TOML")?;
137 text.push('\n');
138 std::fs::write(&path, text).with_context(|| format!("cannot write {}", path.display()))
139}
140
141pub fn deprecated_replacement(field: &str) -> Option<&'static str> {
144 let _ = field;
145 None
146}
147
148pub fn validate_manifest(manifest: &PiteManifest) -> (Vec<String>, Vec<String>) {
151 let mut errors = Vec::new();
152 let mut warnings = Vec::new();
153 let mut unknown: Vec<String> = manifest.extra.keys().cloned().collect();
154 for key in manifest.project.extra.keys() {
155 unknown.push(format!("project.{key}"));
156 }
157 for key in manifest.export.extra.keys() {
158 unknown.push(format!("export.{key}"));
159 }
160 unknown.sort();
161 for field in unknown {
162 if field == "export.platforms" {
163 warnings.push(
164 "manifest: export.platforms is deprecated and ignored (removed in 0.1.1)"
165 .to_string(),
166 );
167 continue;
168 }
169 match deprecated_replacement(&field) {
170 Some(use_instead) => warnings.push(format!(
171 "manifest: {field:?} is deprecated, use {use_instead:?}"
172 )),
173 None => warnings.push(format!("manifest: unknown field {field:?}")),
174 }
175 }
176 if manifest.project.python.is_empty() {
177 errors.push("manifest: project.python must not be empty".to_string());
178 }
179 if manifest.project.window_width == 0 || manifest.project.window_height == 0 {
180 errors.push(format!(
181 "manifest: window is {}x{}, dimensions must be positive",
182 manifest.project.window_width, manifest.project.window_height
183 ));
184 }
185 if !manifest.project.icon.is_empty() && !manifest.project.icon.starts_with("res://") {
186 errors.push(format!(
187 "manifest: icon {:?} must be res:// or empty (absolute paths are forbidden)",
188 manifest.project.icon
189 ));
190 }
191 (errors, warnings)
192}
193
194pub fn find_project_root(start: &Path) -> Option<PathBuf> {
195 let mut dir = if start.is_file() {
196 start.parent()?.to_path_buf()
197 } else {
198 start.to_path_buf()
199 };
200 loop {
201 if dir.join(MANIFEST_FILE).is_file() {
202 return Some(dir);
203 }
204 if !dir.pop() {
205 return None;
206 }
207 }
208}
209
210pub fn resolve_res(project_dir: &Path, res_path: &str) -> Option<PathBuf> {
211 res_path
212 .strip_prefix("res://")
213 .map(|rel| project_dir.join(rel))
214}
215
216pub fn to_res_path(project_dir: &Path, path: &Path) -> Option<String> {
217 path.strip_prefix(project_dir)
218 .ok()
219 .map(|rel| format!("res://{}", rel.to_string_lossy().replace('\\', "/")))
220}
221
222pub const TEMPLATE_PITE_TOML: &str = r#"[project]
223name = "TEMPLATE_NAME"
224pite_version = "0.2.0"
225main_scene = "res://scenes/main.pitescene"
226python = "3.12"
227author = ""
228icon = ""
229window_width = 1280
230window_height = 720
231"#;
232
233pub fn template_scene() -> String {
236 format!(
237 r#"format_version = {}
238root = "root"
239
240[[node]]
241id = "root"
242type = "Node2D"
243name = "Main"
244
245[[node]]
246id = "player"
247type = "Sprite2D"
248name = "Player"
249parent = "root"
250
251[node.props]
252texture = "res://assets/player.png"
253
254[node.script]
255path = "res://scripts/player.py"
256class = "Player"
257
258[[node]]
259id = "cam"
260type = "Camera2D"
261name = "Camera"
262parent = "root"
263
264[node.props]
265position = [100.0, 200.0]
266"#,
267 pite_scene::FORMAT_VERSION
268 )
269}
270
271pub const TEMPLATE_SCRIPT: &str = r#"import pite
272
273
274class Player(pite.Node2D):
275 speed: float = 200.0
276
277 def _ready(self):
278 self.position = (100.0, 200.0)
279
280 def _process(self, delta: float):
281 x, y = self.position
282 if pite.held("ArrowRight"):
283 x += self.speed * delta
284 if pite.held("ArrowLeft"):
285 x -= self.speed * delta
286 if pite.held("ArrowDown"):
287 y += self.speed * delta
288 if pite.held("ArrowUp"):
289 y -= self.speed * delta
290 if pite.pressed("Space"):
291 x += 10.0
292 self.position = (x, y)
293"#;
294
295const TEMPLATE_PLAYER_PNG: &[u8] = &[
298 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,
299 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 255, 127, 0,
300 9, 251, 3, 253, 42, 134, 227, 138, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
301];
302
303pub fn create_project(dest: &Path, name: &str, template: &str) -> Result<PathBuf> {
304 if template != "minimal-2d" {
305 anyhow::bail!("unknown template {template:?} (only \"minimal-2d\" exists)");
306 }
307 let dir = if dest.file_name().map(|n| n == name).unwrap_or(false) {
308 dest.to_path_buf()
309 } else {
310 dest.join(name)
311 };
312 if dir.exists() {
313 anyhow::bail!("{} already exists", dir.display());
314 }
315 std::fs::create_dir_all(dir.join("scenes")).with_context(|| "cannot create project dirs")?;
316 std::fs::create_dir_all(dir.join("scripts"))?;
317 std::fs::create_dir_all(dir.join("assets"))?;
318 std::fs::write(
319 dir.join(MANIFEST_FILE),
320 TEMPLATE_PITE_TOML.replace("TEMPLATE_NAME", name),
321 )?;
322 std::fs::write(dir.join("scenes").join("main.pitescene"), template_scene())?;
323 std::fs::write(dir.join("scripts").join("player.py"), TEMPLATE_SCRIPT)?;
324 std::fs::write(dir.join("assets").join("player.png"), TEMPLATE_PLAYER_PNG)?;
325 Ok(dir)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use std::sync::atomic::{AtomicU64, Ordering};
332
333 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
334
335 fn tmpdir(tag: &str) -> PathBuf {
336 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
337 let dir =
338 std::env::temp_dir().join(format!("pite-manifest-{tag}-{}-{n}", std::process::id()));
339 let _ = std::fs::remove_dir_all(&dir);
340 std::fs::create_dir_all(&dir).unwrap();
341 dir
342 }
343
344 fn load_text(text: &str) -> PiteManifest {
345 let dir = tmpdir("parse");
346 std::fs::write(dir.join(MANIFEST_FILE), text).unwrap();
347 let manifest = load_manifest(&dir).unwrap();
348 std::fs::remove_dir_all(&dir).ok();
349 manifest
350 }
351
352 #[test]
353 fn old_manifest_parses_untouched() {
354 let manifest = load_text("[project]\nname = \"old\"\n");
355 assert_eq!(manifest.project.name, "old");
356 assert_eq!(manifest.project.pite_version, "0.2.0");
357 assert_eq!(manifest.project.main_scene, "res://scenes/main.pitescene");
358 assert_eq!(manifest.project.python, "3.12");
359 assert_eq!(manifest.project.author, "");
360 assert_eq!(manifest.project.icon, "");
361 assert_eq!(manifest.project.window_width, 1280);
362 assert_eq!(manifest.project.window_height, 720);
363 assert!(manifest.project.extra.is_empty());
364 assert!(manifest.vendor.is_empty());
365 let (errors, warnings) = validate_manifest(&manifest);
366 assert!(errors.is_empty(), "got {errors:?}");
367 assert!(warnings.is_empty(), "got {warnings:?}");
368 }
369
370 #[test]
371 fn vendor_table_round_trips() {
372 let dir = tmpdir("vendor");
373 let mut manifest = load_text("[project]\nname = \"t\"\n");
374 manifest.vendor.insert(
375 "requests".to_string(),
376 VendorEntry {
377 version: "2.32.3".to_string(),
378 sha256: "abc".to_string(),
379 direct: true,
380 },
381 );
382 manifest.vendor.insert(
383 "urllib3".to_string(),
384 VendorEntry {
385 version: "2.2.2".to_string(),
386 sha256: "def".to_string(),
387 direct: false,
388 },
389 );
390 save_manifest(&dir, &manifest).unwrap();
391 let back = load_manifest(&dir).unwrap();
392 assert_eq!(back.vendor.len(), 2);
393 assert_eq!(back.vendor["requests"].version, "2.32.3");
394 assert!(back.vendor["requests"].direct);
395 assert!(!back.vendor["urllib3"].direct);
396 std::fs::remove_dir_all(&dir).ok();
397 }
398
399 #[test]
400 fn bad_type_fails_loudly() {
401 let dir = tmpdir("bad-type");
402 std::fs::write(dir.join(MANIFEST_FILE), "[project]\nname = 5\n").unwrap();
403 let err = load_manifest(&dir).unwrap_err();
404 assert!(err.to_string().contains("cannot parse"), "got {err:#}");
405 std::fs::write(
406 dir.join(MANIFEST_FILE),
407 "[project]\nname = \"x\"\nwindow_width = \"wide\"\n",
408 )
409 .unwrap();
410 let err = load_manifest(&dir).unwrap_err();
411 assert!(err.to_string().contains("cannot parse"), "got {err:#}");
412 std::fs::remove_dir_all(&dir).ok();
413 }
414
415 #[test]
416 fn unknown_keys_survive_round_trip() {
417 let manifest = load_text(
418 "custom = 1\n\n[project]\nname = \"x\"\nnickname = \"y\"\n\n[export]\nbundle = true\n",
419 );
420 assert_eq!(
421 manifest.project.extra.get("nickname"),
422 Some(&toml::Value::String("y".to_string()))
423 );
424 let again: PiteManifest = toml::from_str(&toml::to_string(&manifest).unwrap()).unwrap();
425 assert_eq!(manifest.extra, again.extra);
426 assert_eq!(manifest.project.extra, again.project.extra);
427 assert_eq!(manifest.export.extra, again.export.extra);
428 let (_, warnings) = validate_manifest(&again);
429 for field in ["custom", "project.nickname", "export.bundle"] {
430 assert!(
431 warnings.iter().any(|w| w.contains(field)),
432 "missing warning for {field}, got: {warnings:?}"
433 );
434 }
435 }
436
437 #[test]
438 fn impossible_values_are_errors() {
439 let manifest = load_text(
440 "[project]\nname = \"x\"\npython = \"\"\nwindow_width = 0\nicon = \"/abs/icon.png\"\n",
441 );
442 let (errors, _) = validate_manifest(&manifest);
443 assert!(
444 errors.iter().any(|e| e.contains("python")),
445 "got {errors:?}"
446 );
447 assert!(errors.iter().any(|e| e.contains("0x")), "got {errors:?}");
448 assert!(errors.iter().any(|e| e.contains("icon")), "got {errors:?}");
449 }
450
451 #[test]
452 fn save_keeps_unknown_keys_and_edits() {
453 let dir = tmpdir("save");
454 std::fs::write(
455 dir.join(MANIFEST_FILE),
456 "custom = 1\n\n[project]\nname = \"x\"\nnickname = \"y\"\n",
457 )
458 .unwrap();
459 let mut manifest = load_manifest(&dir).unwrap();
460 manifest.project.name = "renamed".to_string();
461 save_manifest(&dir, &manifest).unwrap();
462 let again = load_manifest(&dir).unwrap();
463 assert_eq!(again.project.name, "renamed");
464 assert_eq!(
465 again.project.extra.get("nickname"),
466 Some(&toml::Value::String("y".to_string()))
467 );
468 assert_eq!(again.extra.get("custom"), Some(&toml::Value::Integer(1)));
469 let (errors, _) = validate_manifest(&again);
470 assert!(errors.is_empty(), "got {errors:?}");
471 std::fs::remove_dir_all(&dir).ok();
472 }
473
474 #[test]
475 fn save_is_byte_stable() {
476 let dir = tmpdir("save-stable");
477 std::fs::write(
478 dir.join(MANIFEST_FILE),
479 "zeta = 1\nalpha = 2\n\n[project]\nname = \"x\"\n",
480 )
481 .unwrap();
482 let manifest = load_manifest(&dir).unwrap();
483 save_manifest(&dir, &manifest).unwrap();
484 let first = std::fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap();
485 let reloaded = load_manifest(&dir).unwrap();
486 save_manifest(&dir, &reloaded).unwrap();
487 let second = std::fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap();
488 assert_eq!(first, second, "save churned:\n{first}\n---\n{second}");
489 std::fs::remove_dir_all(&dir).ok();
490 }
491}