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